@qingflow-tech/qingflow-cli 1.1.47-beta.3 → 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 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.3
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.3 qingflow
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.3 qingflow-skills install --agent codex --scope user --copy
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",
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "1.1.47b3"
7
+ version = "1.1.47b5"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -130,12 +130,15 @@ qingflow --json auth whoami
130
130
 
131
131
  默认只交付用户请求的主要结果:安装方案默认提供应用包入口;直接创建或修改应用、视图时提供对应入口;用户明确要求多个主要对象时分别提供。使用业务结果中的稳定 `uri`,按 URI 去重,不为全部子资源预签票据。主要对象没有可靠 URI 时说明限制,不猜地址,也不替换为不相关页面。
132
132
 
133
+ CLI 返回的稳定 `uri` 以 `QINGFLOW_BASE_URL` 为当前环境来源:去除末尾 API 路径后使用同一 HTTP(S) 域名;仅当该变量为空时回退 `https://qingflow.com`。若非空配置非法,保留 URI 不可用诊断,不自行替换或拼接其他环境地址。
134
+
133
135
  部分成功时仅交付已确认成功且可定位的主要结果,保留失败或未验证说明。纯数据问答、中间读取、失败资源和删除操作不自动签发;用户明确要求不提供链接时遵从。用户明确要求查看或打开页面时也使用已确认的路径。
134
136
 
135
- 目标浏览器登录态未知时,临近交付取该 URI 的 pathname 执行:
137
+ 目标浏览器登录态未知时,临近交付取主要结果稳定 `uri` 的完整 pathname 执行,不得丢失应用包、应用或视图层级;视图任务不得退化成应用入口:
136
138
 
137
139
  ```bash
138
- qingflow auth login-link --redirect-path "/resources/ws/<WS_ID>/app/<APP_KEY>"
140
+ qingflow auth login-link --redirect-path "<主要结果 uri 的完整 pathname>"
141
+ # 自定义视图 pathname 示例:/resources/ws/<WS_ID>/app/<APP_KEY>/view/custom/<VIEW_KEY>
139
142
  ```
140
143
 
141
144
  对每个实际展示的入口分别签发,使用本次返回的完整 URL,不额外追问登录态,不复用已消费链接。只有已有用户确认同一浏览器、网页域名、账号和工作区已登录、没有失效信号的事实,才直接交付稳定 `uri`。CLI 已登录、自动化浏览器已登录或曾经成功签发票据都不能证明目标浏览器已登录。
@@ -471,10 +474,11 @@ qingflow auth logout --forget-persisted
471
474
 
472
475
  ```bash
473
476
  qingflow --json auth login-link
474
- qingflow --profile default --json auth login-link --redirect-path "/resources/ws/<WS_ID>/app/<APP_KEY>"
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>
475
479
  ```
476
480
 
477
- - 默认使用当前会话和已选工作区;缺失时按实际错误恢复,不额外预查或重新登录。
481
+ - 默认使用当前会话和已选工作区;缺失时按实际错误恢复,不额外预查或重新登录。pathname 必须保留主要对象的完整层级,视图任务不得改用应用 pathname。
478
482
  - `--redirect-path` 可选,仅接受已确认的站内业务路径,可包含 query/hash;不接受外域或认证入口。示例 ID 仅作格式展示,不得照抄或编造实际目标;省略时进入工作区首页。
479
483
  - 将返回的 `url` 作为可点击链接交付,不修改或拼造。链接短时有效且仅可使用一次,失效后重新获取,不写入公共文档或日志。
480
484
  - 命令不会自动打开浏览器;用户未要求打开时不提前打开或消费链接,明确要求打开时才调用浏览器。
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: qingflow-common
3
- description: 解释轻流公共业务概念并选择 CLI 或 MCP 入口。用于能力比较、记录与任务操作、表单与流程搭建的任务分流,以及从 AI 对话进入页面查看结果、继续编辑、处理业务或进入工作区的场景。
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
- | 免登录打开网页或指定业务页面 | `qingflow --json auth login-link [--redirect-path PATH]` | `get_login_link` |
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
- ## 从 AI 对话进入轻流页面
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),
@@ -2,11 +2,13 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import json
5
+ import os
5
6
  import sys
6
7
  from typing import Any, Callable, TextIO
7
8
 
8
9
  from ..errors import QingflowApiError, backend_code_value_int, message_looks_like_invalid_token
9
10
  from ..public_surface import cli_public_tool_spec_from_namespace
11
+ from ..resource_uri import cli_resource_base_url
10
12
  from ..response_trim import resolve_cli_tool_name, trim_error_response, trim_public_response
11
13
  from ..tools.ai_builder_tools import _attach_builder_apply_envelope
12
14
  from ..version import get_cli_version, get_cli_version_info
@@ -94,7 +96,8 @@ def run(
94
96
  context = context_factory()
95
97
  if not bool(args.json):
96
98
  _emit_cli_effective_context_notice(args, context, stream=err)
97
- result = handler(args, context)
99
+ with cli_resource_base_url(os.getenv("QINGFLOW_BASE_URL") or ""):
100
+ result = handler(args, context)
98
101
  except SystemExit as exc:
99
102
  return int(exc.code or 0)
100
103
  except RuntimeError as exc:
@@ -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),
@@ -1,17 +1,24 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+ from contextvars import ContextVar
4
7
  from dataclasses import dataclass
5
8
  from typing import Any
6
9
  from urllib.parse import quote, unquote, urlsplit, urlunsplit
7
10
 
8
- from .config import get_web_base_url
11
+ from .config import DEFAULT_WEB_BASE_URL, get_web_base_url
9
12
  from .errors import QingflowApiError
10
13
  from .json_types import JSONObject
11
14
 
12
15
 
13
16
  SYSTEM_VIEW_NAMES = frozenset({"all", "initiated", "todo", "done", "cc"})
14
17
  _ENCODED_OCTET = re.compile(r"%[0-9A-Fa-f]{2}")
18
+ _CLI_RESOURCE_BASE_URL: ContextVar[str | None] = ContextVar(
19
+ "qingflow_cli_resource_base_url",
20
+ default=None,
21
+ )
15
22
 
16
23
 
17
24
  @dataclass(frozen=True, slots=True)
@@ -24,6 +31,16 @@ class ResourcePath:
24
31
  view_key: str | None = None
25
32
 
26
33
 
34
+ @contextmanager
35
+ def cli_resource_base_url(value: str) -> Iterator[None]:
36
+ """让一次 CLI 调用的资源 URI 使用 QINGFLOW_BASE_URL 对应的网页环境。"""
37
+ token = _CLI_RESOURCE_BASE_URL.set(value)
38
+ try:
39
+ yield
40
+ finally:
41
+ _CLI_RESOURCE_BASE_URL.reset(token)
42
+
43
+
27
44
  def validate_resource_identity(value: object, field: str = "resource_id") -> str:
28
45
  """按资源 URI 单路径段合同校验并返回规范身份,不读取网页域名配置。"""
29
46
  return _validate_identity(value, field)
@@ -64,7 +81,7 @@ def build_resource_uri(
64
81
  path = f"/resources/ws/{workspace}/app/{app_segment}/view/{view_kind}/{view_segment}"
65
82
  else:
66
83
  raise ValueError("resource_type is not supported")
67
- return f"{_normalize_web_origin(web_origin) if web_origin is not None else get_web_base_url()}{path}"
84
+ return f"{resolve_resource_web_origin(web_origin)}{path}"
68
85
 
69
86
 
70
87
  def parse_resource_path(value: str) -> ResourcePath:
@@ -142,6 +159,46 @@ def _normalize_web_origin(value: str) -> str:
142
159
  return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
143
160
 
144
161
 
162
+ def resolve_resource_web_origin(web_origin: str | None = None) -> str:
163
+ """解析当前调用面的网页根地址;CLI 与 MCP 分别沿用各自的环境配置。"""
164
+ if web_origin is not None:
165
+ return _normalize_web_origin(web_origin)
166
+ cli_base_url = _CLI_RESOURCE_BASE_URL.get()
167
+ if cli_base_url is None:
168
+ return get_web_base_url()
169
+ return _web_origin_from_api_base_url(cli_base_url)
170
+
171
+
172
+ def _web_origin_from_api_base_url(value: str) -> str:
173
+ normalized = value.strip()
174
+ if not normalized:
175
+ return DEFAULT_WEB_BASE_URL
176
+ try:
177
+ parsed = urlsplit(normalized)
178
+ host = parsed.hostname or ""
179
+ valid_host = bool(host) and (":" in host or all(
180
+ re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label)
181
+ for label in host.rstrip(".").split(".")
182
+ ))
183
+ if (
184
+ parsed.scheme not in {"http", "https"}
185
+ or not valid_host
186
+ or host.lower() in {"null", "undefined"}
187
+ or parsed.username is not None
188
+ or parsed.password is not None
189
+ or parsed.query
190
+ or parsed.fragment
191
+ or any(character.isspace() or ord(character) < 32 for character in normalized)
192
+ ):
193
+ raise ValueError
194
+ _ = parsed.port
195
+ return _normalize_web_origin(urlunsplit((parsed.scheme, parsed.netloc, "", "", "")))
196
+ except ValueError:
197
+ raise QingflowApiError.config_error(
198
+ "QINGFLOW_BASE_URL 必须是有效的 HTTP(S) API 地址,不包含查询参数、片段或用户凭证"
199
+ ) from None
200
+
201
+
145
202
  def _identity_segment(value: object, field: str) -> str:
146
203
  raw = _validate_identity(value, field)
147
204
  return quote(raw, safe="")
@@ -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, get_web_base_url
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 = get_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
- # 后端仅提供票据,网页地址由 MCP 配置确定,避免工作区域名为空。
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
- files: list[JSONObject] = []
2269
- shard_part = 0
2270
- shard_rows = 0
2271
- row_count = 0
2272
- file_handle: Any | None = None
2273
- writer: Any | None = None
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
- csv_writer.writerow(values)
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,