@qingflow-tech/qingflow-cli 1.1.47-beta.4 → 1.1.47-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/SKILL.md +6 -4
- package/skills/qingflow-common/SKILL.md +6 -25
- package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
- package/src/qingflow_mcp/cli/commands/data.py +107 -0
- package/src/qingflow_mcp/cli/context.py +3 -0
- package/src/qingflow_mcp/public_surface.py +5 -0
- package/src/qingflow_mcp/resource_uri.py +3 -2
- package/src/qingflow_mcp/server.py +2 -0
- package/src/qingflow_mcp/server_app_user.py +4 -0
- package/src/qingflow_mcp/tools/csv_export.py +168 -0
- package/src/qingflow_mcp/tools/data_tools.py +240 -0
- package/src/qingflow_mcp/tools/login_link_tools.py +4 -4
- package/src/qingflow_mcp/tools/record_tools.py +11 -46
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Qingflow CLI 是轻流面向开发者和 AI Agent 的命令行工具。它把轻
|
|
|
7
7
|
## 安装
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm install @qingflow-tech/qingflow-cli@1.1.47-beta.
|
|
10
|
+
npm install @qingflow-tech/qingflow-cli@1.1.47-beta.5
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
推荐从官网复制这一条一键安装 Qingflow CLI:
|
|
@@ -21,7 +21,7 @@ npx qingflow-cli@latest install
|
|
|
21
21
|
## 运行
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.47-beta.
|
|
24
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.47-beta.5 qingflow
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
全局安装后可以直接运行:
|
|
@@ -62,7 +62,7 @@ qingflow-skills install --agent codex --scope user
|
|
|
62
62
|
如果你通过一次性 `npx -p` 执行 skills 安装,建议加 `--copy`,避免 symlink 指向 npm 临时执行缓存:
|
|
63
63
|
|
|
64
64
|
```bash
|
|
65
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.47-beta.
|
|
65
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.47-beta.5 qingflow-skills install --agent codex --scope user --copy
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
`qingflow-skills install` 默认使用 symlink,且遇到同名 skill 不会覆盖;只有显式传入 `--force` 才会替换。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qingflow-tech/qingflow-cli",
|
|
3
|
-
"version": "1.1.47-beta.
|
|
3
|
+
"version": "1.1.47-beta.5",
|
|
4
4
|
"description": "Human-friendly Qingflow command line interface for auth, record operations, import, tasks, and stable builder flows.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/pyproject.toml
CHANGED
|
@@ -134,10 +134,11 @@ CLI 返回的稳定 `uri` 以 `QINGFLOW_BASE_URL` 为当前环境来源:去除
|
|
|
134
134
|
|
|
135
135
|
部分成功时仅交付已确认成功且可定位的主要结果,保留失败或未验证说明。纯数据问答、中间读取、失败资源和删除操作不自动签发;用户明确要求不提供链接时遵从。用户明确要求查看或打开页面时也使用已确认的路径。
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
目标浏览器登录态未知时,临近交付取主要结果稳定 `uri` 的完整 pathname 执行,不得丢失应用包、应用或视图层级;视图任务不得退化成应用入口:
|
|
138
138
|
|
|
139
139
|
```bash
|
|
140
|
-
qingflow auth login-link --redirect-path "
|
|
140
|
+
qingflow auth login-link --redirect-path "<主要结果 uri 的完整 pathname>"
|
|
141
|
+
# 自定义视图 pathname 示例:/resources/ws/<WS_ID>/app/<APP_KEY>/view/custom/<VIEW_KEY>
|
|
141
142
|
```
|
|
142
143
|
|
|
143
144
|
对每个实际展示的入口分别签发,使用本次返回的完整 URL,不额外追问登录态,不复用已消费链接。只有已有用户确认同一浏览器、网页域名、账号和工作区已登录、没有失效信号的事实,才直接交付稳定 `uri`。CLI 已登录、自动化浏览器已登录或曾经成功签发票据都不能证明目标浏览器已登录。
|
|
@@ -473,10 +474,11 @@ qingflow auth logout --forget-persisted
|
|
|
473
474
|
|
|
474
475
|
```bash
|
|
475
476
|
qingflow --json auth login-link
|
|
476
|
-
qingflow --profile default --json auth login-link --redirect-path "
|
|
477
|
+
qingflow --profile default --json auth login-link --redirect-path "<主要结果 uri 的完整 pathname>"
|
|
478
|
+
# 自定义视图 pathname 示例:/resources/ws/<WS_ID>/app/<APP_KEY>/view/custom/<VIEW_KEY>
|
|
477
479
|
```
|
|
478
480
|
|
|
479
|
-
- 默认使用当前会话和已选工作区;缺失时按实际错误恢复,不额外预查或重新登录。
|
|
481
|
+
- 默认使用当前会话和已选工作区;缺失时按实际错误恢复,不额外预查或重新登录。pathname 必须保留主要对象的完整层级,视图任务不得改用应用 pathname。
|
|
480
482
|
- `--redirect-path` 可选,仅接受已确认的站内业务路径,可包含 query/hash;不接受外域或认证入口。示例 ID 仅作格式展示,不得照抄或编造实际目标;省略时进入工作区首页。
|
|
481
483
|
- 将返回的 `url` 作为可点击链接交付,不修改或拼造。链接短时有效且仅可使用一次,失效后重新获取,不写入公共文档或日志。
|
|
482
484
|
- 命令不会自动打开浏览器;用户未要求打开时不提前打开或消费链接,明确要求打开时才调用浏览器。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: qingflow-common
|
|
3
|
-
description:
|
|
3
|
+
description: Explain shared Qingflow concepts, choose the CLI or MCP surface, and route resource work or page-link delivery to the relevant domain Skill.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Qingflow Common Concepts
|
|
@@ -31,7 +31,7 @@ Do not require both surfaces. If only one is installed, complete the workflow on
|
|
|
31
31
|
| Form schema/read/apply/delete | `qingflow --json builder app-form ...` / `builder app delete` | `app_form_schema`, `app_form_get`, `app_form_apply`, `app_delete` |
|
|
32
32
|
| AppForm static validation | `qingflow --json builder app-form validate --file ...` | Performed inside `app_form_apply`; no separate tool |
|
|
33
33
|
| Record and task work | `qingflow --json record ...` / `task ...` | Qingflow MCP record and task tools |
|
|
34
|
-
|
|
|
34
|
+
| Open a workspace or confirmed business page | `qingflow --json auth login-link [--redirect-path PATH]` | `get_login_link` |
|
|
35
35
|
| Package, view, portal, chart, workflow | `qingflow --json builder ...` | Qingflow MCP configuration tools |
|
|
36
36
|
|
|
37
37
|
The operation names and payload schemas are defined by the public surface. Use the current Schema or tool input schema as the source of truth, not a legacy command name or backend payload.
|
|
@@ -43,29 +43,10 @@ The operation names and payload schemas are defined by the public surface. Use t
|
|
|
43
43
|
|
|
44
44
|
Each package includes this common Skill. The CLI package installs its CLI guidance; the MCP package installs every MCP domain Skill. The source of every packaged Skill is `qingflow-support/mcp-server/skills/`.
|
|
45
45
|
|
|
46
|
-
##
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
- 默认只交付用户请求的主要结果:安装方案默认提供应用包入口;直接创建或修改应用、视图时提供对应入口;用户明确要求多个主要对象时分别提供。使用业务结果中的稳定 `uri`,按 URI 去重,不为全部子资源预签票据。
|
|
51
|
-
- 主要对象没有可靠 URI 时说明限制,不猜地址,也不替换为不相关页面。部分成功时仅交付已确认成功且可定位的主要结果,保留失败或未验证说明,不把链接当作整个任务成功的证明。
|
|
52
|
-
- 纯数据问答、中间读取、失败资源和删除操作不自动签发;用户明确要求不提供链接时遵从。
|
|
53
|
-
- MCP 调用 `get_login_link`;CLI 调用 `qingflow --json auth login-link --redirect-path <资源 pathname>`。
|
|
54
|
-
|
|
55
|
-
- 目标路径使用用户提供或业务工具确认的站内路径,不根据应用、记录标识猜测地址。
|
|
56
|
-
- 用户明确指定页面但路径未知时继续定位或说明限制,不猜测,也不静默替换成首页。搜索未命中时继续搜索 `login` 或查看服务工具列表,不擅自打开固定正式站点;失败时如实说明。
|
|
57
|
-
|
|
58
|
-
- 默认使用当前会话及已选工作区;需要指定会话时使用 MCP 的 `profile` 或 CLI 的 `--profile`。
|
|
59
|
-
- `redirect_path` 可选,默认空:省略时进入工作区首页;传入时仅支持以单个 `/` 开头的站内业务路径,可带 query/hash。不得传完整 URL、外部地址、认证入口或多层编码路径。
|
|
60
|
-
- 示例:`get_login_link({})`;指定目标示例:`get_login_link({"redirect_path":"/index/app/123?view=abc"})`。示例标识仅作格式展示,实际目标必须来自用户提供或已经确认的页面信息,不得编造应用、记录标识。
|
|
61
|
-
- 成功响应仅有 `url` 和毫秒整数 `expires_at`。临近交付时再签发,使用本次返回的完整链接作为主要点击入口,不展示工作区数字 ID 或原始 JSON。
|
|
62
|
-
- CLI 示例:`qingflow --json auth login-link`;指定目标使用 `qingflow --profile default --json auth login-link --redirect-path "/index/app/123?view=abc"`,同样不得编造实际路径。
|
|
63
|
-
- 目标浏览器状态未知时,默认按每个待交付资源分别签发新链接,不额外追问登录态。只有已有用户确认目标是同一浏览器、网页域名、账号和工作区且已登录、没有失效信号的事实,才直接交付稳定 `uri`。调用过票据工具、自动化浏览器已登录或之前签发过票据都不是该事实;已消费链接不可复用。
|
|
64
|
-
- 链接约 5 分钟内只能兑换一次,不作为长期书签;过期或使用后重新调用,不修改票据或拼造链接;只交付给请求用户,不写入公共文档或日志。
|
|
65
|
-
- 用户未要求打开时仅交付,不提前消费;要求“打开”时才使用可用浏览器打开。浏览器不可用时提供可点击链接,不假称已经打开。
|
|
66
|
-
- 签发失败时说明免登录链接未生成,并提供标注“需登录”的稳定 `uri` 作为备用入口,不重做业务操作。机器结果继续保留稳定 URI,临时登录链接仅用于面向用户交付。
|
|
67
|
-
- 必要的 MFA、密码安全验证仍执行,完成后先切换当前 MCP 工作区,再进入目标页面;业务权限仍由目标页面校验,不因链接而扩大。
|
|
68
|
-
- 工具拒绝非法跳转参数;浏览器收到被篡改的非法目标时忽略目标并进入首页。网页域名由 MCP 配置 `QINGFLOW_MCP_WEB_BASE_URL` 决定,不从目标路径选择其他环境。
|
|
46
|
+
## Deliver Qingflow Page Links
|
|
47
|
+
|
|
48
|
+
- After creating, changing, or installing resources, finish with the result and a clickable entry for each requested primary result. Use confirmed stable `uri` values, deduplicate them, and link only confirmed successes. Do not auto-issue links for data-only answers, intermediate reads, failed or deleted resources, or when the user declines links.
|
|
49
|
+
- When browser login is unknown, issue one fresh link per displayed destination near delivery through the installed surface. Never guess, replace, open, or consume a destination unless authorized. If issuance fails, keep the business result and offer the stable URI as requiring login when available. Follow the CLI Skill or MCP tool description for signing details.
|
|
69
50
|
|
|
70
51
|
## Route Next
|
|
71
52
|
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
|
|
5
|
-
from . import app, auth, builder, chart, exports, imports, portal, record, task, view, workspace
|
|
5
|
+
from . import app, auth, builder, chart, data, exports, imports, portal, record, task, view, workspace
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def register_all_commands(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
@@ -13,6 +13,7 @@ def register_all_commands(subparsers: argparse._SubParsersAction[argparse.Argume
|
|
|
13
13
|
view.register(subparsers)
|
|
14
14
|
chart.register(subparsers)
|
|
15
15
|
record.register(subparsers)
|
|
16
|
+
data.register(subparsers)
|
|
16
17
|
imports.register(subparsers)
|
|
17
18
|
exports.register(subparsers)
|
|
18
19
|
task.register(subparsers)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from ..context import CliContext
|
|
6
|
+
from .common import load_list_arg, require_list_arg, require_object_arg, raise_config_error
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
10
|
+
parser = subparsers.add_parser("data", help="视图无关的应用数据面")
|
|
11
|
+
commands = parser.add_subparsers(dest="data_command", required=True, metavar="{query,get,insert,update,delete}")
|
|
12
|
+
|
|
13
|
+
query = commands.add_parser("query", help="分页查询应用数据")
|
|
14
|
+
query.add_argument("--app-key", required=True)
|
|
15
|
+
query.add_argument("--columns-file")
|
|
16
|
+
query.add_argument("--where-file")
|
|
17
|
+
query.add_argument("--sort-file")
|
|
18
|
+
query.add_argument("--search", default="")
|
|
19
|
+
query.add_argument("--page", type=int, default=1)
|
|
20
|
+
query.add_argument("--page-size", type=int, default=100)
|
|
21
|
+
query.add_argument("--no-schema", action="store_false", dest="include_schema", default=True)
|
|
22
|
+
query.add_argument("--all", action="store_true", dest="all_pages")
|
|
23
|
+
query.add_argument("--output", choices=("json", "csv"), default="json")
|
|
24
|
+
query.set_defaults(handler=_handle_query, format_hint="data_query")
|
|
25
|
+
|
|
26
|
+
get = commands.add_parser("get", help="读取单条应用数据")
|
|
27
|
+
get.add_argument("--app-key", required=True)
|
|
28
|
+
get.add_argument("--record-id", required=True)
|
|
29
|
+
get.add_argument("--no-schema", action="store_false", dest="include_schema", default=True)
|
|
30
|
+
get.set_defaults(handler=_handle_get, format_hint="data_get")
|
|
31
|
+
|
|
32
|
+
insert = commands.add_parser("insert", help="新增应用数据")
|
|
33
|
+
insert.add_argument("--app-key", required=True)
|
|
34
|
+
insert.add_argument("--items-file", required=True)
|
|
35
|
+
insert.add_argument("--no-schema", action="store_false", dest="include_schema", default=True)
|
|
36
|
+
insert.set_defaults(handler=_handle_insert, format_hint="data_insert")
|
|
37
|
+
|
|
38
|
+
update = commands.add_parser("update", help="更新应用数据")
|
|
39
|
+
update.add_argument("--app-key", required=True)
|
|
40
|
+
update.add_argument("--record-id", required=True)
|
|
41
|
+
update.add_argument("--fields-file", required=True)
|
|
42
|
+
update.add_argument("--no-schema", action="store_false", dest="include_schema", default=True)
|
|
43
|
+
update.set_defaults(handler=_handle_update, format_hint="data_update")
|
|
44
|
+
|
|
45
|
+
delete = commands.add_parser("delete", help="删除应用数据")
|
|
46
|
+
delete.add_argument("--app-key", required=True)
|
|
47
|
+
delete.add_argument(
|
|
48
|
+
"--record-ids", action="append", default=[], metavar="ID[,ID...]",
|
|
49
|
+
help="待删除记录 ID;支持逗号分隔,也可重复传入",
|
|
50
|
+
)
|
|
51
|
+
delete.add_argument("--record-ids-file", help="附加的记录 ID JSON 数组文件")
|
|
52
|
+
delete.set_defaults(handler=_handle_delete, format_hint="data_delete")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _handle_query(args: argparse.Namespace, context: CliContext) -> dict:
|
|
56
|
+
if args.all_pages != (args.output == "csv"):
|
|
57
|
+
raise_config_error(
|
|
58
|
+
"--all 与 --output csv 必须同时使用。",
|
|
59
|
+
fix_hint="使用 `qingflow data query ... --all --output csv`,普通分页查询不要传这两个参数。",
|
|
60
|
+
)
|
|
61
|
+
return context.data.data_query(
|
|
62
|
+
profile=args.profile,
|
|
63
|
+
app_key=args.app_key,
|
|
64
|
+
columns=load_list_arg(args.columns_file, option_name="--columns-file"),
|
|
65
|
+
where=load_list_arg(args.where_file, option_name="--where-file"),
|
|
66
|
+
sort=load_list_arg(args.sort_file, option_name="--sort-file"),
|
|
67
|
+
search=args.search,
|
|
68
|
+
page=args.page,
|
|
69
|
+
page_size=args.page_size,
|
|
70
|
+
include_schema=args.include_schema,
|
|
71
|
+
all_pages=args.all_pages,
|
|
72
|
+
output=args.output,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
|
|
77
|
+
return context.data.data_get(
|
|
78
|
+
profile=args.profile, app_key=args.app_key, record_id=args.record_id, include_schema=args.include_schema
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _handle_insert(args: argparse.Namespace, context: CliContext) -> dict:
|
|
83
|
+
return context.data.data_insert(
|
|
84
|
+
profile=args.profile, app_key=args.app_key,
|
|
85
|
+
items=require_list_arg(args.items_file, option_name="--items-file"), include_schema=args.include_schema,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _handle_update(args: argparse.Namespace, context: CliContext) -> dict:
|
|
90
|
+
return context.data.data_update(
|
|
91
|
+
profile=args.profile, app_key=args.app_key, record_id=args.record_id,
|
|
92
|
+
fields=require_object_arg(args.fields_file, option_name="--fields-file"), include_schema=args.include_schema,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _handle_delete(args: argparse.Namespace, context: CliContext) -> dict:
|
|
97
|
+
record_ids = [item.strip() for group in args.record_ids for item in group.split(",") if item.strip()]
|
|
98
|
+
record_ids.extend(load_list_arg(args.record_ids_file, option_name="--record-ids-file"))
|
|
99
|
+
if not record_ids:
|
|
100
|
+
raise_config_error(
|
|
101
|
+
"至少传入一个记录 ID。",
|
|
102
|
+
fix_hint="使用 `--record-ids ID[,ID...]`;也可附加 `--record-ids-file ids.json`。",
|
|
103
|
+
)
|
|
104
|
+
return context.data.data_delete(
|
|
105
|
+
profile=args.profile, app_key=args.app_key,
|
|
106
|
+
record_ids=record_ids,
|
|
107
|
+
)
|
|
@@ -11,6 +11,7 @@ from ..tools.ai_builder_tools import AiBuilderTools
|
|
|
11
11
|
from ..tools.app_tools import AppTools
|
|
12
12
|
from ..tools.auth_tools import AuthTools
|
|
13
13
|
from ..tools.code_block_tools import CodeBlockTools
|
|
14
|
+
from ..tools.data_tools import DataTools
|
|
14
15
|
from ..tools.export_tools import ExportTools
|
|
15
16
|
from ..tools.feedback_tools import FeedbackTools
|
|
16
17
|
from ..tools.file_tools import FileTools
|
|
@@ -33,6 +34,7 @@ class CliContext:
|
|
|
33
34
|
app: AppTools
|
|
34
35
|
resource: ResourceReadTools
|
|
35
36
|
record: RecordTools
|
|
37
|
+
data: DataTools
|
|
36
38
|
code_block: CodeBlockTools
|
|
37
39
|
imports: ImportTools
|
|
38
40
|
exports: ExportTools
|
|
@@ -64,6 +66,7 @@ def build_cli_context() -> CliContext:
|
|
|
64
66
|
app=AppTools(sessions, backend),
|
|
65
67
|
resource=ResourceReadTools(sessions, backend),
|
|
66
68
|
record=RecordTools(sessions, backend),
|
|
69
|
+
data=DataTools(sessions, backend),
|
|
67
70
|
code_block=CodeBlockTools(sessions, backend),
|
|
68
71
|
imports=ImportTools(sessions, backend),
|
|
69
72
|
exports=ExportTools(sessions, backend),
|
|
@@ -40,6 +40,11 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
40
40
|
PublicToolSpec(USER_DOMAIN, "workspace_select", ("workspace_select",), ("workspace", "select")),
|
|
41
41
|
PublicToolSpec(USER_DOMAIN, "app_list", ("app_list",), ("app", "list"), cli_show_effective_context=True),
|
|
42
42
|
PublicToolSpec(USER_DOMAIN, "app_get", ("app_get",), ("app", "get"), cli_show_effective_context=True),
|
|
43
|
+
PublicToolSpec(USER_DOMAIN, "data_query", ("data_query",), ("data", "query"), cli_show_effective_context=True),
|
|
44
|
+
PublicToolSpec(USER_DOMAIN, "data_get", ("data_get",), ("data", "get"), cli_show_effective_context=True),
|
|
45
|
+
PublicToolSpec(USER_DOMAIN, "data_insert", ("data_insert",), ("data", "insert"), cli_show_effective_context=True, cli_context_write=True),
|
|
46
|
+
PublicToolSpec(USER_DOMAIN, "data_update", ("data_update",), ("data", "update"), cli_show_effective_context=True, cli_context_write=True),
|
|
47
|
+
PublicToolSpec(USER_DOMAIN, "data_delete", ("data_delete",), ("data", "delete"), cli_show_effective_context=True, cli_context_write=True),
|
|
43
48
|
PublicToolSpec(USER_DOMAIN, "portal_list", ("portal_list",), ("portal", "list"), cli_show_effective_context=True),
|
|
44
49
|
PublicToolSpec(USER_DOMAIN, "portal_get", ("portal_get",), ("portal", "get"), cli_show_effective_context=True),
|
|
45
50
|
PublicToolSpec(USER_DOMAIN, "view_get", ("view_get",), ("view", "get"), cli_show_effective_context=True),
|
|
@@ -81,7 +81,7 @@ def build_resource_uri(
|
|
|
81
81
|
path = f"/resources/ws/{workspace}/app/{app_segment}/view/{view_kind}/{view_segment}"
|
|
82
82
|
else:
|
|
83
83
|
raise ValueError("resource_type is not supported")
|
|
84
|
-
return f"{
|
|
84
|
+
return f"{resolve_resource_web_origin(web_origin)}{path}"
|
|
85
85
|
|
|
86
86
|
|
|
87
87
|
def parse_resource_path(value: str) -> ResourcePath:
|
|
@@ -159,7 +159,8 @@ def _normalize_web_origin(value: str) -> str:
|
|
|
159
159
|
return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
|
|
160
160
|
|
|
161
161
|
|
|
162
|
-
def
|
|
162
|
+
def resolve_resource_web_origin(web_origin: str | None = None) -> str:
|
|
163
|
+
"""解析当前调用面的网页根地址;CLI 与 MCP 分别沿用各自的环境配置。"""
|
|
163
164
|
if web_origin is not None:
|
|
164
165
|
return _normalize_web_origin(web_origin)
|
|
165
166
|
cli_base_url = _CLI_RESOURCE_BASE_URL.get()
|
|
@@ -7,6 +7,7 @@ from mcp.server.fastmcp import FastMCP
|
|
|
7
7
|
from .backend_client import BackendClient
|
|
8
8
|
from .session_store import SessionStore
|
|
9
9
|
from .tools.app_tools import AppTools
|
|
10
|
+
from .tools.data_tools import DataTools
|
|
10
11
|
from .tools.auth_tools import AuthTools
|
|
11
12
|
from .tools.code_block_tools import CodeBlockTools
|
|
12
13
|
from .tools.feedback_tools import FeedbackTools
|
|
@@ -249,6 +250,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
|
|
|
249
250
|
TaskContextTools(sessions, backend).register(server)
|
|
250
251
|
RoleTools(sessions, backend).register(server)
|
|
251
252
|
AppTools(sessions, backend).register(server)
|
|
253
|
+
DataTools(sessions, backend).register(server)
|
|
252
254
|
QingbiReportTools(sessions, backend).register(server)
|
|
253
255
|
PackageTools(sessions, backend).register(server)
|
|
254
256
|
NavigationTools(sessions, backend).register(server)
|
|
@@ -10,6 +10,7 @@ from .config import DEFAULT_PROFILE
|
|
|
10
10
|
from .response_trim import USER_SERVER_METHOD_MAP, trim_error_response, trim_public_response, wrap_trimmed_methods
|
|
11
11
|
from .session_store import SessionStore
|
|
12
12
|
from .tools.app_tools import AppTools
|
|
13
|
+
from .tools.data_tools import DataTools
|
|
13
14
|
from .tools.auth_tools import AuthTools
|
|
14
15
|
from .tools.code_block_tools import CodeBlockTools
|
|
15
16
|
from .tools.directory_tools import DirectoryTools
|
|
@@ -242,6 +243,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
|
|
|
242
243
|
backend = BackendClient()
|
|
243
244
|
auth = wrap_trimmed_methods(AuthTools(sessions, backend), USER_SERVER_METHOD_MAP)
|
|
244
245
|
apps = wrap_trimmed_methods(AppTools(sessions, backend), USER_SERVER_METHOD_MAP)
|
|
246
|
+
data_tools = wrap_trimmed_methods(DataTools(sessions, backend), USER_SERVER_METHOD_MAP)
|
|
245
247
|
workspace = wrap_trimmed_methods(WorkspaceTools(sessions, backend), USER_SERVER_METHOD_MAP)
|
|
246
248
|
file_tools = wrap_trimmed_methods(FileTools(sessions, backend), USER_SERVER_METHOD_MAP)
|
|
247
249
|
imports = wrap_trimmed_methods(ImportTools(sessions, backend), USER_SERVER_METHOD_MAP)
|
|
@@ -395,6 +397,8 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
|
|
|
395
397
|
def app_get(profile: str = DEFAULT_PROFILE, app_key: str = "") -> dict:
|
|
396
398
|
return apps.app_get(profile=profile, app_key=app_key)
|
|
397
399
|
|
|
400
|
+
data_tools.register(server)
|
|
401
|
+
|
|
398
402
|
@server.tool()
|
|
399
403
|
def portal_list(profile: str = DEFAULT_PROFILE) -> dict:
|
|
400
404
|
return resources.portal_list(profile=profile)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from ..json_types import JSONObject
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
PageFetcher = Callable[[int, int], JSONObject]
|
|
13
|
+
RowProjector = Callable[[JSONObject], list[str]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CsvPageFailure(RuntimeError):
|
|
17
|
+
"""后续分页返回结构化业务错误;已写文件保留为不完整结果。"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, payload: JSONObject) -> None:
|
|
20
|
+
super().__init__(str(payload.get("message") or "分页请求失败"))
|
|
21
|
+
self.payload = payload
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CsvShardWriter:
|
|
25
|
+
"""视图无关的 UTF-8 BOM CSV 分片写入器。"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, *, output_dir: Path, file_prefix: str, header: list[str], shard_rows: int) -> None:
|
|
28
|
+
self.output_dir = output_dir
|
|
29
|
+
self.file_prefix = file_prefix
|
|
30
|
+
self.header = header
|
|
31
|
+
self.shard_rows = shard_rows
|
|
32
|
+
self.files: list[JSONObject] = []
|
|
33
|
+
self.row_count = 0
|
|
34
|
+
self._part = 0
|
|
35
|
+
self._current_rows = 0
|
|
36
|
+
self._file: Any | None = None
|
|
37
|
+
self._writer: Any | None = None
|
|
38
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
def write(self, values: list[str]) -> None:
|
|
41
|
+
if self._current_rows >= self.shard_rows:
|
|
42
|
+
self.close_shard()
|
|
43
|
+
self._ensure_writer().writerow(values)
|
|
44
|
+
self._current_rows += 1
|
|
45
|
+
self.row_count += 1
|
|
46
|
+
|
|
47
|
+
def close_shard(self) -> None:
|
|
48
|
+
if self._file is None:
|
|
49
|
+
return
|
|
50
|
+
path = Path(self._file.name)
|
|
51
|
+
self._file.close()
|
|
52
|
+
self.files.append({"local_path": str(path), "row_count": self._current_rows, "part": len(self.files) + 1})
|
|
53
|
+
self._file = None
|
|
54
|
+
self._writer = None
|
|
55
|
+
self._current_rows = 0
|
|
56
|
+
|
|
57
|
+
def _ensure_writer(self) -> Any:
|
|
58
|
+
if self._writer is not None:
|
|
59
|
+
return self._writer
|
|
60
|
+
self._part += 1
|
|
61
|
+
path = self.output_dir / f"{self.file_prefix}-{self._part:04d}.csv"
|
|
62
|
+
self._file = path.open("w", newline="", encoding="utf-8-sig")
|
|
63
|
+
self._writer = csv.writer(self._file)
|
|
64
|
+
self._writer.writerow(self.header)
|
|
65
|
+
return self._writer
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def export_csv_pages(
|
|
69
|
+
*,
|
|
70
|
+
fetch_page: PageFetcher,
|
|
71
|
+
header: list[str],
|
|
72
|
+
project_row: RowProjector,
|
|
73
|
+
output_dir: Path,
|
|
74
|
+
file_prefix: str,
|
|
75
|
+
page_size: int,
|
|
76
|
+
shard_rows: int,
|
|
77
|
+
time_budget_seconds: float,
|
|
78
|
+
) -> JSONObject:
|
|
79
|
+
"""执行与业务权限无关的分页、CSV 分片和时间预算控制。"""
|
|
80
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
deadline = time.monotonic() + time_budget_seconds
|
|
82
|
+
files: list[JSONObject] = []
|
|
83
|
+
row_count = 0
|
|
84
|
+
fetched_pages = 0
|
|
85
|
+
current_page = 1
|
|
86
|
+
file_handle: Any | None = None
|
|
87
|
+
writer: Any | None = None
|
|
88
|
+
current_rows = 0
|
|
89
|
+
part = 0
|
|
90
|
+
has_more = False
|
|
91
|
+
stopped_reason: str | None = None
|
|
92
|
+
page_error: JSONObject | None = None
|
|
93
|
+
|
|
94
|
+
def close_file() -> None:
|
|
95
|
+
nonlocal file_handle, writer, current_rows
|
|
96
|
+
if file_handle is None:
|
|
97
|
+
return
|
|
98
|
+
path = Path(file_handle.name)
|
|
99
|
+
file_handle.close()
|
|
100
|
+
files.append({"local_path": str(path), "row_count": current_rows, "part": len(files) + 1})
|
|
101
|
+
file_handle = None
|
|
102
|
+
writer = None
|
|
103
|
+
current_rows = 0
|
|
104
|
+
|
|
105
|
+
def get_writer() -> Any:
|
|
106
|
+
nonlocal file_handle, writer, part
|
|
107
|
+
if writer is not None:
|
|
108
|
+
return writer
|
|
109
|
+
part += 1
|
|
110
|
+
path = output_dir / f"{file_prefix}-{part:04d}.csv"
|
|
111
|
+
file_handle = path.open("w", newline="", encoding="utf-8-sig")
|
|
112
|
+
writer = csv.writer(file_handle)
|
|
113
|
+
writer.writerow(header)
|
|
114
|
+
return writer
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
while True:
|
|
118
|
+
page = fetch_page(current_page, page_size)
|
|
119
|
+
records = page.get("records") if isinstance(page, dict) else None
|
|
120
|
+
container = records if isinstance(records, dict) else page
|
|
121
|
+
raw_items = container.get("items", container.get("list", [])) if isinstance(container, dict) else []
|
|
122
|
+
items = raw_items if isinstance(raw_items, list) else []
|
|
123
|
+
has_more = bool(container.get("has_more")) if isinstance(container, dict) else False
|
|
124
|
+
for item in items:
|
|
125
|
+
if not isinstance(item, dict):
|
|
126
|
+
continue
|
|
127
|
+
if current_rows >= shard_rows:
|
|
128
|
+
close_file()
|
|
129
|
+
get_writer().writerow(project_row(item))
|
|
130
|
+
current_rows += 1
|
|
131
|
+
row_count += 1
|
|
132
|
+
fetched_pages += 1
|
|
133
|
+
if not has_more:
|
|
134
|
+
break
|
|
135
|
+
if time.monotonic() >= deadline:
|
|
136
|
+
stopped_reason = "TIME_BUDGET_EXCEEDED"
|
|
137
|
+
break
|
|
138
|
+
current_page += 1
|
|
139
|
+
except CsvPageFailure as exception:
|
|
140
|
+
stopped_reason = "PAGE_FETCH_FAILED"
|
|
141
|
+
page_error = exception.payload
|
|
142
|
+
except Exception:
|
|
143
|
+
# 已落盘分片仍是有效的部分结果;调用方据此禁止形成完整性结论。
|
|
144
|
+
stopped_reason = "PAGE_FETCH_FAILED"
|
|
145
|
+
finally:
|
|
146
|
+
close_file()
|
|
147
|
+
|
|
148
|
+
complete = stopped_reason is None and not has_more
|
|
149
|
+
result: JSONObject = {
|
|
150
|
+
"files": files,
|
|
151
|
+
"row_count": row_count,
|
|
152
|
+
"complete": complete,
|
|
153
|
+
"truncated": not complete,
|
|
154
|
+
"safe_for_final_conclusion": complete,
|
|
155
|
+
"fetched_pages": fetched_pages,
|
|
156
|
+
"stopped_reason": stopped_reason,
|
|
157
|
+
}
|
|
158
|
+
if page_error is not None:
|
|
159
|
+
result["page_error"] = page_error
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def csv_cell(value: Any) -> str:
|
|
164
|
+
if value is None:
|
|
165
|
+
return ""
|
|
166
|
+
if isinstance(value, (dict, list)):
|
|
167
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
168
|
+
return str(value)
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from mcp.server.fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from ..backend_client import BackendRequestContext
|
|
10
|
+
from ..config import DEFAULT_PROFILE
|
|
11
|
+
from ..errors import QingflowApiError, raise_tool_error
|
|
12
|
+
from ..json_types import JSONObject, JSONValue
|
|
13
|
+
from .base import ToolBase
|
|
14
|
+
from .csv_export import CsvPageFailure, csv_cell, export_csv_pages
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DATA_PAGE_SIZE = 1000
|
|
18
|
+
DATA_CSV_SHARD_ROWS = 20_000
|
|
19
|
+
DATA_UNBOUNDED_ROW_THRESHOLD = 50_000
|
|
20
|
+
DATA_CSV_TIME_BUDGET_SECONDS = 55.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DataTools(ToolBase):
|
|
24
|
+
"""视图无关的应用数据面工具。"""
|
|
25
|
+
|
|
26
|
+
def register(self, mcp: FastMCP) -> None:
|
|
27
|
+
@mcp.tool()
|
|
28
|
+
def data_query(
|
|
29
|
+
profile: str = DEFAULT_PROFILE,
|
|
30
|
+
app_key: str = "",
|
|
31
|
+
columns: list[int] | None = None,
|
|
32
|
+
where: list[dict] | None = None,
|
|
33
|
+
sort: list[dict] | None = None,
|
|
34
|
+
search: str = "",
|
|
35
|
+
page: int = 1,
|
|
36
|
+
page_size: int = 100,
|
|
37
|
+
include_schema: bool = True,
|
|
38
|
+
all_pages: bool = False,
|
|
39
|
+
output: str = "json",
|
|
40
|
+
) -> JSONObject:
|
|
41
|
+
return self.data_query(
|
|
42
|
+
profile=profile, app_key=app_key, columns=columns or [], where=where or [], sort=sort or [],
|
|
43
|
+
search=search, page=page, page_size=page_size, include_schema=include_schema,
|
|
44
|
+
all_pages=all_pages, output=output,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@mcp.tool()
|
|
48
|
+
def data_get(
|
|
49
|
+
profile: str = DEFAULT_PROFILE, app_key: str = "", record_id: str = "", include_schema: bool = True
|
|
50
|
+
) -> JSONObject:
|
|
51
|
+
return self.data_get(profile=profile, app_key=app_key, record_id=record_id, include_schema=include_schema)
|
|
52
|
+
|
|
53
|
+
@mcp.tool()
|
|
54
|
+
def data_insert(
|
|
55
|
+
profile: str = DEFAULT_PROFILE, app_key: str = "", items: list[dict] | None = None,
|
|
56
|
+
include_schema: bool = True,
|
|
57
|
+
) -> JSONObject:
|
|
58
|
+
return self.data_insert(profile=profile, app_key=app_key, items=items or [], include_schema=include_schema)
|
|
59
|
+
|
|
60
|
+
@mcp.tool()
|
|
61
|
+
def data_update(
|
|
62
|
+
profile: str = DEFAULT_PROFILE, app_key: str = "", record_id: str = "",
|
|
63
|
+
fields: dict | None = None, include_schema: bool = True,
|
|
64
|
+
) -> JSONObject:
|
|
65
|
+
return self.data_update(
|
|
66
|
+
profile=profile, app_key=app_key, record_id=record_id, fields=fields or {},
|
|
67
|
+
include_schema=include_schema,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@mcp.tool()
|
|
71
|
+
def data_delete(
|
|
72
|
+
profile: str = DEFAULT_PROFILE, app_key: str = "", record_ids: list[str | int] | None = None,
|
|
73
|
+
) -> JSONObject:
|
|
74
|
+
return self.data_delete(profile=profile, app_key=app_key, record_ids=record_ids or [])
|
|
75
|
+
|
|
76
|
+
def data_query(
|
|
77
|
+
self, *, profile: str, app_key: str, columns: list[int], where: list[dict], sort: list[dict],
|
|
78
|
+
search: str = "", page: int = 1, page_size: int = 100, include_schema: bool = True,
|
|
79
|
+
all_pages: bool = False, output: str = "json",
|
|
80
|
+
) -> JSONObject:
|
|
81
|
+
self._require_app_key(app_key)
|
|
82
|
+
if all_pages and output != "csv":
|
|
83
|
+
raise_tool_error(QingflowApiError.config_error("all_pages 仅支持 output=csv"))
|
|
84
|
+
|
|
85
|
+
def runner(session_profile, context):
|
|
86
|
+
body: JSONObject = {
|
|
87
|
+
"page": page, "page_size": page_size, "columns": columns, "where": where, "sort": sort,
|
|
88
|
+
"search": search, "include_schema": include_schema,
|
|
89
|
+
}
|
|
90
|
+
if not all_pages:
|
|
91
|
+
return self._attach_context(
|
|
92
|
+
self._request_data_plane(context, "POST", self._base_path(app_key) + "/query", json_body=body),
|
|
93
|
+
profile, session_profile.selected_ws_id,
|
|
94
|
+
)
|
|
95
|
+
return self._export_all(
|
|
96
|
+
context=context, profile=profile, ws_id=session_profile.selected_ws_id, app_key=app_key,
|
|
97
|
+
body=body, has_scope=bool(where or search),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return self._run(profile, runner, tool_name="数据面查询")
|
|
101
|
+
|
|
102
|
+
def data_get(self, *, profile: str, app_key: str, record_id: str | int, include_schema: bool = True) -> JSONObject:
|
|
103
|
+
self._require_app_key(app_key)
|
|
104
|
+
self._require_record_id(record_id)
|
|
105
|
+
return self._run(
|
|
106
|
+
profile,
|
|
107
|
+
lambda session_profile, context: self._attach_context(
|
|
108
|
+
self._request_data_plane(
|
|
109
|
+
context, "GET", f"{self._base_path(app_key)}/{record_id}",
|
|
110
|
+
params={"include_schema": include_schema},
|
|
111
|
+
), profile, session_profile.selected_ws_id,
|
|
112
|
+
),
|
|
113
|
+
tool_name="数据面单条读取",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def data_insert(self, *, profile: str, app_key: str, items: list[dict], include_schema: bool = True) -> JSONObject:
|
|
117
|
+
self._require_app_key(app_key)
|
|
118
|
+
return self._write(profile, app_key, "POST", self._base_path(app_key), {"items": items, "include_schema": include_schema}, "数据面新增")
|
|
119
|
+
|
|
120
|
+
def data_update(
|
|
121
|
+
self, *, profile: str, app_key: str, record_id: str | int, fields: dict, include_schema: bool = True
|
|
122
|
+
) -> JSONObject:
|
|
123
|
+
self._require_app_key(app_key)
|
|
124
|
+
self._require_record_id(record_id)
|
|
125
|
+
return self._write(
|
|
126
|
+
profile, app_key, "POST", f"{self._base_path(app_key)}/{record_id}/update",
|
|
127
|
+
{"fields": fields, "include_schema": include_schema}, "数据面更新",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def data_delete(self, *, profile: str, app_key: str, record_ids: list[str | int]) -> JSONObject:
|
|
131
|
+
self._require_app_key(app_key)
|
|
132
|
+
return self._write(profile, app_key, "POST", self._base_path(app_key) + "/delete", {"record_ids": record_ids}, "数据面删除")
|
|
133
|
+
|
|
134
|
+
def _write(self, profile: str, app_key: str, method: str, path: str, body: JSONObject, tool_name: str) -> JSONObject:
|
|
135
|
+
return self._run(
|
|
136
|
+
profile,
|
|
137
|
+
lambda session_profile, context: self._attach_context(
|
|
138
|
+
self._request_data_plane(context, method, path, json_body=body), profile, session_profile.selected_ws_id,
|
|
139
|
+
),
|
|
140
|
+
tool_name=tool_name,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _request_data_plane(
|
|
144
|
+
self, context: BackendRequestContext, method: str, path: str, *, params: JSONObject | None = None,
|
|
145
|
+
json_body: JSONValue = None,
|
|
146
|
+
) -> JSONObject:
|
|
147
|
+
raw = self.backend.request(method, context, path, params=params, json_body=json_body, unwrap=False)
|
|
148
|
+
if not isinstance(raw, dict):
|
|
149
|
+
raise QingflowApiError(category="backend", message="数据面返回了非对象响应")
|
|
150
|
+
code = raw.get("code")
|
|
151
|
+
payload = raw.get("data")
|
|
152
|
+
result: JSONObject = dict(payload) if isinstance(payload, dict) else {}
|
|
153
|
+
success = code in (None, 0, 200, 20000) or str(code).lower() in {"success", "ok"}
|
|
154
|
+
result.setdefault("ok", success)
|
|
155
|
+
result.setdefault("status", "success" if success else "failed")
|
|
156
|
+
result.setdefault("hints", [])
|
|
157
|
+
if not success:
|
|
158
|
+
result.setdefault("message", str(raw.get("message") or raw.get("msg") or "数据面请求失败"))
|
|
159
|
+
result.setdefault("backend_code", code)
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
def _export_all(
|
|
163
|
+
self, *, context: BackendRequestContext, profile: str, ws_id: int | None, app_key: str,
|
|
164
|
+
body: JSONObject, has_scope: bool,
|
|
165
|
+
) -> JSONObject:
|
|
166
|
+
probe_body = dict(body)
|
|
167
|
+
probe_body.update({"page": 1, "page_size": 1, "include_schema": True})
|
|
168
|
+
probe = self._request_data_plane(context, "POST", self._base_path(app_key) + "/query", json_body=probe_body)
|
|
169
|
+
if probe.get("ok") is False:
|
|
170
|
+
# 探测请求没有产生文件,保留后端结构化错误,不把失败误报为空数据成功。
|
|
171
|
+
return self._attach_context(probe, profile, ws_id)
|
|
172
|
+
records = probe.get("records") if isinstance(probe.get("records"), dict) else {}
|
|
173
|
+
total = records.get("total") if isinstance(records, dict) else None
|
|
174
|
+
if isinstance(total, int) and total > DATA_UNBOUNDED_ROW_THRESHOLD and not has_scope:
|
|
175
|
+
return self._attach_context({
|
|
176
|
+
"ok": True, "status": "needs_scope", "app_key": app_key, "format": "csv", "row_count": 0,
|
|
177
|
+
"complete": False, "truncated": True, "safe_for_final_conclusion": False, "files": [],
|
|
178
|
+
"hints": probe.get("hints", []),
|
|
179
|
+
"scope": {"reported_total": total, "row_threshold": DATA_UNBOUNDED_ROW_THRESHOLD},
|
|
180
|
+
}, profile, ws_id)
|
|
181
|
+
schema = probe.get("schema") if isinstance(probe.get("schema"), dict) else {}
|
|
182
|
+
fields = schema.get("fields") if isinstance(schema, dict) else None
|
|
183
|
+
field_list = fields if isinstance(fields, list) else []
|
|
184
|
+
business_fields = [field for field in field_list if isinstance(field, dict) and field.get("field_id") is not None]
|
|
185
|
+
columns = [(str(field.get("field_id")), str(field.get("title") or field.get("field_id"))) for field in business_fields]
|
|
186
|
+
header = ["record_id"] + [f"{title}__field_{field_id}" for field_id, title in columns]
|
|
187
|
+
run_dir = Path.home() / ".qingflow" / "data-exports" / datetime.now().strftime("%Y%m%d-%H%M%S-%f")
|
|
188
|
+
|
|
189
|
+
def fetch_page(page: int, page_size: int) -> JSONObject:
|
|
190
|
+
request_body = dict(body)
|
|
191
|
+
request_body.update({"page": page, "page_size": page_size, "include_schema": page == 1})
|
|
192
|
+
response = self._request_data_plane(
|
|
193
|
+
context, "POST", self._base_path(app_key) + "/query", json_body=request_body
|
|
194
|
+
)
|
|
195
|
+
if response.get("ok") is False:
|
|
196
|
+
raise CsvPageFailure(response)
|
|
197
|
+
return response
|
|
198
|
+
|
|
199
|
+
def project_row(record: JSONObject) -> list[str]:
|
|
200
|
+
values = record.get("fields") if isinstance(record.get("fields"), dict) else record
|
|
201
|
+
return [str(record.get("record_id") or "")] + [csv_cell(values.get(field_id)) for field_id, _ in columns]
|
|
202
|
+
|
|
203
|
+
exported = export_csv_pages(
|
|
204
|
+
fetch_page=fetch_page, header=header, project_row=project_row, output_dir=run_dir,
|
|
205
|
+
file_prefix="records", page_size=DATA_PAGE_SIZE, shard_rows=DATA_CSV_SHARD_ROWS,
|
|
206
|
+
time_budget_seconds=DATA_CSV_TIME_BUDGET_SECONDS,
|
|
207
|
+
)
|
|
208
|
+
page_error = exported.pop("page_error", None)
|
|
209
|
+
result: JSONObject = {
|
|
210
|
+
"ok": True, "status": "success" if exported["complete"] else "partial", "app_key": app_key,
|
|
211
|
+
"format": "csv", "local_dir": str(run_dir), "fields": business_fields, "hints": probe.get("hints", []),
|
|
212
|
+
**exported,
|
|
213
|
+
}
|
|
214
|
+
if isinstance(page_error, dict):
|
|
215
|
+
# 后续页失败时保留已落盘分片,同时把后端稳定错误字段原样带回。
|
|
216
|
+
result["ok"] = False
|
|
217
|
+
for key in ("error_code", "message", "backend_code", "write_executed", "retry_safe", "field_errors"):
|
|
218
|
+
if key in page_error:
|
|
219
|
+
result[key] = page_error[key]
|
|
220
|
+
if "hints" in page_error:
|
|
221
|
+
result["hints"] = page_error["hints"]
|
|
222
|
+
return self._attach_context(result, profile, ws_id)
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def _base_path(app_key: str) -> str:
|
|
226
|
+
return f"/app/{app_key}/data/v1/records"
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _attach_context(result: JSONObject, profile: str, ws_id: int | None) -> JSONObject:
|
|
230
|
+
return {"profile": profile, "ws_id": ws_id, **result}
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def _require_app_key(app_key: str) -> None:
|
|
234
|
+
if not str(app_key or "").strip():
|
|
235
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def _require_record_id(record_id: str | int) -> None:
|
|
239
|
+
if not str(record_id or "").strip():
|
|
240
|
+
raise_tool_error(QingflowApiError.config_error("record_id is required"))
|
|
@@ -6,10 +6,10 @@ from urllib.parse import parse_qs, unquote, urlencode, urlsplit
|
|
|
6
6
|
|
|
7
7
|
from mcp.server.fastmcp import FastMCP
|
|
8
8
|
|
|
9
|
-
from ..config import DEFAULT_PROFILE
|
|
9
|
+
from ..config import DEFAULT_PROFILE
|
|
10
10
|
from ..errors import QingflowApiError
|
|
11
11
|
from ..json_types import JSONObject
|
|
12
|
-
from ..resource_uri import parse_resource_path
|
|
12
|
+
from ..resource_uri import parse_resource_path, resolve_resource_web_origin
|
|
13
13
|
from .base import ToolBase
|
|
14
14
|
|
|
15
15
|
LOGIN_LINK_DESCRIPTION = (
|
|
@@ -99,14 +99,14 @@ class LoginLinkTools(ToolBase):
|
|
|
99
99
|
)
|
|
100
100
|
else:
|
|
101
101
|
target = validate_redirect_path(requested_target)
|
|
102
|
-
web_base_url =
|
|
102
|
+
web_base_url = resolve_resource_web_origin()
|
|
103
103
|
result = self.backend.request(
|
|
104
104
|
"POST",
|
|
105
105
|
context,
|
|
106
106
|
"/mcp/auth/login-link",
|
|
107
107
|
json_body={"wsId": session_profile.selected_ws_id},
|
|
108
108
|
)
|
|
109
|
-
#
|
|
109
|
+
# 后端仅提供票据;CLI 使用 QINGFLOW_BASE_URL 对应网页环境,MCP 使用独立网页配置。
|
|
110
110
|
try:
|
|
111
111
|
source_url = result.get("url") if isinstance(result, dict) else None
|
|
112
112
|
if not isinstance(source_url, str):
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import csv
|
|
4
3
|
import html
|
|
5
4
|
import mimetypes
|
|
6
5
|
import json
|
|
@@ -34,6 +33,7 @@ from ..list_type_labels import (
|
|
|
34
33
|
)
|
|
35
34
|
from .base import ToolBase
|
|
36
35
|
from .directory_tools import _directory_has_more, _directory_items
|
|
36
|
+
from .csv_export import CsvShardWriter
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
DEFAULT_QUERY_PAGE_SIZE = 50
|
|
@@ -2263,57 +2263,22 @@ class RecordTools(ToolBase):
|
|
|
2263
2263
|
}
|
|
2264
2264
|
|
|
2265
2265
|
run_dir = _record_access_run_dir()
|
|
2266
|
-
run_dir.mkdir(parents=True, exist_ok=True)
|
|
2267
2266
|
header = ["record_id"] + [str(field["column_name"]) for field in fields_payload]
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
def close_shard() -> None:
|
|
2276
|
-
nonlocal file_handle, writer, shard_rows
|
|
2277
|
-
if file_handle is None:
|
|
2278
|
-
return
|
|
2279
|
-
path = Path(file_handle.name)
|
|
2280
|
-
file_handle.close()
|
|
2281
|
-
files.append(
|
|
2282
|
-
{
|
|
2283
|
-
"local_path": str(path),
|
|
2284
|
-
"row_count": shard_rows,
|
|
2285
|
-
"part": len(files) + 1,
|
|
2286
|
-
}
|
|
2287
|
-
)
|
|
2288
|
-
file_handle = None
|
|
2289
|
-
writer = None
|
|
2290
|
-
shard_rows = 0
|
|
2291
|
-
|
|
2292
|
-
def ensure_writer() -> Any:
|
|
2293
|
-
nonlocal file_handle, writer, shard_part
|
|
2294
|
-
if writer is not None:
|
|
2295
|
-
return writer
|
|
2296
|
-
shard_part += 1
|
|
2297
|
-
path = run_dir / f"records-{shard_part:04d}.csv"
|
|
2298
|
-
file_handle = path.open("w", newline="", encoding="utf-8-sig")
|
|
2299
|
-
writer = csv.writer(file_handle)
|
|
2300
|
-
writer.writerow(header)
|
|
2301
|
-
return writer
|
|
2267
|
+
shard_writer = CsvShardWriter(
|
|
2268
|
+
output_dir=run_dir,
|
|
2269
|
+
file_prefix="records",
|
|
2270
|
+
header=header,
|
|
2271
|
+
shard_rows=DEFAULT_RECORD_ACCESS_SHARD_ROWS,
|
|
2272
|
+
)
|
|
2302
2273
|
|
|
2303
2274
|
def write_record(apply_id: int | None, answer_list: list[JSONValue]) -> None:
|
|
2304
|
-
nonlocal row_count, shard_rows
|
|
2305
2275
|
if apply_id is None:
|
|
2306
2276
|
return
|
|
2307
|
-
if shard_rows >= DEFAULT_RECORD_ACCESS_SHARD_ROWS:
|
|
2308
|
-
close_shard()
|
|
2309
|
-
csv_writer = ensure_writer()
|
|
2310
2277
|
values: list[str] = [_public_record_id_text(apply_id) or ""]
|
|
2311
2278
|
for field in selected_fields:
|
|
2312
2279
|
answer = _find_answer_for_field(answer_list, field)
|
|
2313
2280
|
values.append(_record_access_csv_cell(_normalize_answer_field_value_for_output(answer, field)))
|
|
2314
|
-
|
|
2315
|
-
row_count += 1
|
|
2316
|
-
shard_rows += 1
|
|
2281
|
+
shard_writer.write(values)
|
|
2317
2282
|
|
|
2318
2283
|
current_page = 1
|
|
2319
2284
|
has_more = False
|
|
@@ -2353,7 +2318,7 @@ class RecordTools(ToolBase):
|
|
|
2353
2318
|
break
|
|
2354
2319
|
page = fetch_page(current_page, BACKEND_RECORD_ACCESS_PAGE_SIZE)
|
|
2355
2320
|
finally:
|
|
2356
|
-
close_shard()
|
|
2321
|
+
shard_writer.close_shard()
|
|
2357
2322
|
|
|
2358
2323
|
if stopped_reason == "TIME_BUDGET_EXCEEDED":
|
|
2359
2324
|
warnings.append(_record_access_time_budget_warning(reported_total=reported_total, fetched_pages=fetched_pages))
|
|
@@ -2378,11 +2343,11 @@ class RecordTools(ToolBase):
|
|
|
2378
2343
|
"view_id": view_route.view_id,
|
|
2379
2344
|
"format": "csv",
|
|
2380
2345
|
"local_dir": str(run_dir),
|
|
2381
|
-
"row_count": row_count,
|
|
2346
|
+
"row_count": shard_writer.row_count,
|
|
2382
2347
|
"complete": complete,
|
|
2383
2348
|
"truncated": not complete,
|
|
2384
2349
|
"safe_for_final_conclusion": safe_for_final_conclusion,
|
|
2385
|
-
"files": files,
|
|
2350
|
+
"files": shard_writer.files,
|
|
2386
2351
|
"fields": fields_payload,
|
|
2387
2352
|
"warnings": warnings,
|
|
2388
2353
|
"verification": verification_payload,
|