@qingflow-tech/qingflow-cli 1.1.49 → 1.1.50-beta.2
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 +4 -4
- package/npm/lib/runtime.mjs +29 -13
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/skills/qingflow-cli/SKILL.md +92 -410
- package/skills/qingflow-cli/manifest.yaml +1 -1
- package/skills/qingflow-cli/reference/00-INDEX.md +4 -4
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +3 -137
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +14 -14
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +7 -12
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
- package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +2 -2
- package/skills/qingflow-cli/reference/builder/30-schema-fields.md +9 -5
- package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +3 -3
- package/skills/qingflow-cli/reference/builder/workflow/03-flow-patterns.md +13 -5
- package/skills/qingflow-cli/reference/builder/workflow/07-stage4-validate-spec.md +5 -3
- package/skills/qingflow-cli/reference/builder/workflow/08-stage5-apply-verify.md +1 -1
- package/skills/qingflow-cli/reference/builder/workflow/11-troubleshooting.md +1 -1
- package/skills/qingflow-cli/reference/builder/workflow/workflow-schema.json +1665 -1738
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +76 -95
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +13 -13
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
- package/skills/qingflow-cli/reference/data/README.md +32 -0
- package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md +50 -0
- package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
- package/skills/qingflow-cli/reference/record/analysis/README.md +21 -113
- package/skills/qingflow-cli/reference/record/analysis/analysis-gotchas.md +10 -10
- package/skills/qingflow-cli/reference/record/analysis/analysis-patterns.md +9 -9
- package/skills/qingflow-cli/reference/record/analysis/business-context.md +1 -1
- package/skills/qingflow-cli/reference/record/analysis/confidence-reporting.md +10 -10
- package/skills/qingflow-cli/reference/record/analysis/data-access-playbook.md +52 -80
- package/skills/qingflow-cli/reference/record/analysis/pandas-recipes.md +2 -2
- package/skills/qingflow-cli/scripts/workflow/validate_flow_spec.py +48 -46
- package/skills/qingflow-common/SKILL.md +10 -17
- package/src/qingflow_mcp/app_form_compat.py +3 -0
- package/src/qingflow_mcp/app_form_validator.py +16 -3
- package/src/qingflow_mcp/backend_client.py +142 -12
- package/src/qingflow_mcp/builder_facade/app_form.py +360 -68
- package/src/qingflow_mcp/builder_facade/flow_validation.py +569 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +1 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +61 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +5 -9
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +8 -15
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +43 -12
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +58 -24
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +8 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +5 -10
- package/src/qingflow_mcp/builder_facade/service.py +749 -6
- package/src/qingflow_mcp/builder_facade/workflow_spec.py +154 -2
- package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
- package/src/qingflow_mcp/cli/commands/auth.py +1 -1
- package/src/qingflow_mcp/cli/commands/builder.py +57 -41
- package/src/qingflow_mcp/cli/commands/data.py +134 -0
- package/src/qingflow_mcp/cli/commands/record.py +1 -1
- package/src/qingflow_mcp/cli/context.py +4 -1
- package/src/qingflow_mcp/cli/formatters.py +159 -4
- package/src/qingflow_mcp/cli/main.py +4 -1
- package/src/qingflow_mcp/config.py +60 -0
- package/src/qingflow_mcp/public_surface.py +9 -0
- package/src/qingflow_mcp/resource_uri.py +241 -0
- package/src/qingflow_mcp/response_trim.py +53 -5
- package/src/qingflow_mcp/server.py +2 -0
- package/src/qingflow_mcp/server_app_builder.py +57 -2
- package/src/qingflow_mcp/server_app_user.py +10 -3
- package/src/qingflow_mcp/tools/ai_builder_tools.py +374 -41
- package/src/qingflow_mcp/tools/app_tools.py +52 -13
- package/src/qingflow_mcp/tools/csv_export.py +168 -0
- package/src/qingflow_mcp/tools/data_tools.py +284 -0
- package/src/qingflow_mcp/tools/login_link_tools.py +34 -6
- package/src/qingflow_mcp/tools/record_tools.py +11 -46
- package/src/qingflow_mcp/tools/resource_read_tools.py +13 -0
|
@@ -10,6 +10,7 @@ from ..config import DEFAULT_PROFILE
|
|
|
10
10
|
from ..errors import QingflowApiError, backend_code_int, is_auth_like_error, raise_tool_error
|
|
11
11
|
from ..json_types import JSONObject
|
|
12
12
|
from ..list_type_labels import SYSTEM_VIEW_DEFINITIONS, get_app_publish_status_label
|
|
13
|
+
from ..resource_uri import try_build_resource_uri
|
|
13
14
|
from ..solution.compiler.icon_utils import workspace_icon_config
|
|
14
15
|
from .base import ToolBase
|
|
15
16
|
|
|
@@ -46,6 +47,15 @@ class AppTools(ToolBase):
|
|
|
46
47
|
unfiltered_count = len(items)
|
|
47
48
|
if normalized_query:
|
|
48
49
|
items = self._filter_visible_apps(items, normalized_query)
|
|
50
|
+
for item in items:
|
|
51
|
+
uri, warning = try_build_resource_uri(
|
|
52
|
+
ws_id=session_profile.selected_ws_id,
|
|
53
|
+
resource_type="app",
|
|
54
|
+
app_key=item.get("app_key"),
|
|
55
|
+
)
|
|
56
|
+
if uri:
|
|
57
|
+
item["uri"] = uri
|
|
58
|
+
_append_unique_warning(warnings, warning)
|
|
49
59
|
response = {
|
|
50
60
|
"profile": profile,
|
|
51
61
|
"ws_id": session_profile.selected_ws_id,
|
|
@@ -200,9 +210,10 @@ class AppTools(ToolBase):
|
|
|
200
210
|
if not _is_optional_app_metadata_error(exc):
|
|
201
211
|
raise
|
|
202
212
|
visible_app = self._resolve_visible_app(context, app_key)
|
|
203
|
-
if visible_app is
|
|
204
|
-
|
|
205
|
-
|
|
213
|
+
if visible_app is None:
|
|
214
|
+
raise
|
|
215
|
+
app_name = str(visible_app.get("app_name") or visible_app.get("title") or app_key).strip() or app_key
|
|
216
|
+
app_icon = str(visible_app.get("app_icon") or "").strip() or None
|
|
206
217
|
warnings.append(
|
|
207
218
|
_with_api_error_fields(
|
|
208
219
|
{
|
|
@@ -224,22 +235,45 @@ class AppTools(ToolBase):
|
|
|
224
235
|
accessible_views.extend(custom_views)
|
|
225
236
|
import_capability, import_warnings = _derive_import_capability(base_info)
|
|
226
237
|
warnings.extend(import_warnings)
|
|
238
|
+
app_uri, uri_warning = try_build_resource_uri(
|
|
239
|
+
ws_id=session_profile.selected_ws_id,
|
|
240
|
+
resource_type="app",
|
|
241
|
+
app_key=app_key,
|
|
242
|
+
)
|
|
243
|
+
_append_unique_warning(warnings, uri_warning)
|
|
244
|
+
for view in accessible_views:
|
|
245
|
+
view_id = str(view.get("view_id") or "")
|
|
246
|
+
view_kind, separator, view_key = view_id.partition(":")
|
|
247
|
+
if not separator:
|
|
248
|
+
continue
|
|
249
|
+
uri, warning = try_build_resource_uri(
|
|
250
|
+
ws_id=session_profile.selected_ws_id,
|
|
251
|
+
resource_type="view",
|
|
252
|
+
app_key=app_key,
|
|
253
|
+
view_kind=view_kind,
|
|
254
|
+
view_key=view_key,
|
|
255
|
+
)
|
|
256
|
+
if uri:
|
|
257
|
+
view["uri"] = uri
|
|
258
|
+
_append_unique_warning(warnings, warning)
|
|
259
|
+
data: JSONObject = {
|
|
260
|
+
"app_key": app_key,
|
|
261
|
+
"app_name": app_name,
|
|
262
|
+
"app_icon": app_icon,
|
|
263
|
+
"icon_config": workspace_icon_config(app_icon),
|
|
264
|
+
**({"uri": app_uri} if app_uri else {}),
|
|
265
|
+
**({"visible_app": visible_app} if base_info is None and visible_app is not None else {}),
|
|
266
|
+
"can_create": can_create,
|
|
267
|
+
"import_capability": import_capability,
|
|
268
|
+
"accessible_views": accessible_views,
|
|
269
|
+
}
|
|
227
270
|
return {
|
|
228
271
|
"profile": profile,
|
|
229
272
|
"ws_id": session_profile.selected_ws_id,
|
|
230
273
|
"ok": True,
|
|
231
274
|
"request_route": {"base_url": context.base_url, "qf_version": context.qf_version},
|
|
232
275
|
"warnings": warnings,
|
|
233
|
-
"data":
|
|
234
|
-
"app_key": app_key,
|
|
235
|
-
"app_name": app_name,
|
|
236
|
-
"app_icon": app_icon,
|
|
237
|
-
"icon_config": workspace_icon_config(app_icon),
|
|
238
|
-
**({"visible_app": visible_app} if base_info is None and visible_app is not None else {}),
|
|
239
|
-
"can_create": can_create,
|
|
240
|
-
"import_capability": import_capability,
|
|
241
|
-
"accessible_views": accessible_views,
|
|
242
|
-
},
|
|
276
|
+
"data": data,
|
|
243
277
|
}
|
|
244
278
|
|
|
245
279
|
return self._run(profile, runner, tool_name='应用详情')
|
|
@@ -1048,6 +1082,11 @@ def _derive_editability(base_info: Any) -> tuple[JSONObject, list[JSONObject]]:
|
|
|
1048
1082
|
}, warnings
|
|
1049
1083
|
|
|
1050
1084
|
|
|
1085
|
+
def _append_unique_warning(warnings: list[JSONObject], warning: JSONObject | None) -> None:
|
|
1086
|
+
if warning and not any(item.get("code") == warning.get("code") for item in warnings):
|
|
1087
|
+
warnings.append(warning)
|
|
1088
|
+
|
|
1089
|
+
|
|
1051
1090
|
def _coerce_positive_int(value: Any) -> int | None:
|
|
1052
1091
|
try:
|
|
1053
1092
|
number = int(value)
|
|
@@ -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,284 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Literal, TypedDict
|
|
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
|
+
DataFilterOperator = Literal[
|
|
23
|
+
"eq", "ne", "contains", "not_contains", "gt", "gte", "lt", "lte", "in", "not_in"
|
|
24
|
+
]
|
|
25
|
+
DataSortDirection = Literal["asc", "desc"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DataWhereItem(TypedDict):
|
|
29
|
+
"""数据面筛选项;in/not_in 使用数组,其余操作符使用单值。"""
|
|
30
|
+
|
|
31
|
+
field_id: int
|
|
32
|
+
op: DataFilterOperator
|
|
33
|
+
value: JSONValue
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DataSortItem(TypedDict):
|
|
37
|
+
"""数据面排序项。"""
|
|
38
|
+
|
|
39
|
+
field_id: int
|
|
40
|
+
direction: DataSortDirection
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DataInsertItem(TypedDict):
|
|
44
|
+
"""数据面新增项,fields 的键为字符串形式 field_id。"""
|
|
45
|
+
|
|
46
|
+
fields: dict[str, JSONValue]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DataTools(ToolBase):
|
|
50
|
+
"""视图无关的应用数据面工具。"""
|
|
51
|
+
|
|
52
|
+
def register(self, mcp: FastMCP) -> None:
|
|
53
|
+
@mcp.tool()
|
|
54
|
+
def data_query(
|
|
55
|
+
profile: str = DEFAULT_PROFILE,
|
|
56
|
+
app_key: str = "",
|
|
57
|
+
columns: list[int] | None = None,
|
|
58
|
+
where: list[DataWhereItem] | None = None,
|
|
59
|
+
sort: list[DataSortItem] | None = None,
|
|
60
|
+
search: str = "",
|
|
61
|
+
page: int = 1,
|
|
62
|
+
page_size: int = 100,
|
|
63
|
+
include_schema: bool = True,
|
|
64
|
+
all_pages: bool = False,
|
|
65
|
+
output: str = "json",
|
|
66
|
+
) -> JSONObject:
|
|
67
|
+
"""按字段 ID 查询应用数据;先读取 field_capabilities 再构造 where 和 sort。"""
|
|
68
|
+
return self.data_query(
|
|
69
|
+
profile=profile, app_key=app_key, columns=columns or [], where=where or [], sort=sort or [],
|
|
70
|
+
search=search, page=page, page_size=page_size, include_schema=include_schema,
|
|
71
|
+
all_pages=all_pages, output=output,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@mcp.tool()
|
|
75
|
+
def data_get(
|
|
76
|
+
profile: str = DEFAULT_PROFILE, app_key: str = "", record_id: str = "", include_schema: bool = True
|
|
77
|
+
) -> JSONObject:
|
|
78
|
+
return self.data_get(profile=profile, app_key=app_key, record_id=record_id, include_schema=include_schema)
|
|
79
|
+
|
|
80
|
+
@mcp.tool()
|
|
81
|
+
def data_insert(
|
|
82
|
+
profile: str = DEFAULT_PROFILE, app_key: str = "", items: list[DataInsertItem] | None = None,
|
|
83
|
+
include_schema: bool = True,
|
|
84
|
+
) -> JSONObject:
|
|
85
|
+
"""新增应用数据;每项形如 {"fields": {"字段ID": 值}}。"""
|
|
86
|
+
return self.data_insert(profile=profile, app_key=app_key, items=items or [], include_schema=include_schema)
|
|
87
|
+
|
|
88
|
+
@mcp.tool()
|
|
89
|
+
def data_update(
|
|
90
|
+
profile: str = DEFAULT_PROFILE, app_key: str = "", record_id: str = "",
|
|
91
|
+
fields: dict | None = None, include_schema: bool = True,
|
|
92
|
+
) -> JSONObject:
|
|
93
|
+
return self.data_update(
|
|
94
|
+
profile=profile, app_key=app_key, record_id=record_id, fields=fields or {},
|
|
95
|
+
include_schema=include_schema,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@mcp.tool()
|
|
99
|
+
def data_delete(
|
|
100
|
+
profile: str = DEFAULT_PROFILE, app_key: str = "", record_ids: list[str | int] | None = None,
|
|
101
|
+
) -> JSONObject:
|
|
102
|
+
return self.data_delete(profile=profile, app_key=app_key, record_ids=record_ids or [])
|
|
103
|
+
|
|
104
|
+
def data_query(
|
|
105
|
+
self, *, profile: str, app_key: str, columns: list[int], where: list[DataWhereItem],
|
|
106
|
+
sort: list[DataSortItem],
|
|
107
|
+
search: str = "", page: int = 1, page_size: int = 100, include_schema: bool = True,
|
|
108
|
+
all_pages: bool = False, output: str = "json",
|
|
109
|
+
) -> JSONObject:
|
|
110
|
+
self._require_app_key(app_key)
|
|
111
|
+
if all_pages and output != "csv":
|
|
112
|
+
raise_tool_error(QingflowApiError.config_error("all_pages 仅支持 output=csv"))
|
|
113
|
+
|
|
114
|
+
def runner(session_profile, context):
|
|
115
|
+
body: JSONObject = {
|
|
116
|
+
"page": page, "page_size": page_size, "columns": columns, "where": where, "sort": sort,
|
|
117
|
+
"search": search, "include_schema": include_schema,
|
|
118
|
+
}
|
|
119
|
+
if not all_pages:
|
|
120
|
+
return self._attach_context(
|
|
121
|
+
self._request_data_plane(context, "POST", self._base_path(app_key) + "/query", json_body=body),
|
|
122
|
+
profile, session_profile.selected_ws_id,
|
|
123
|
+
)
|
|
124
|
+
return self._export_all(
|
|
125
|
+
context=context, profile=profile, ws_id=session_profile.selected_ws_id, app_key=app_key,
|
|
126
|
+
body=body, has_scope=bool(where or search),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return self._run(profile, runner, tool_name="数据面查询")
|
|
130
|
+
|
|
131
|
+
def data_get(self, *, profile: str, app_key: str, record_id: str | int, include_schema: bool = True) -> JSONObject:
|
|
132
|
+
self._require_app_key(app_key)
|
|
133
|
+
self._require_record_id(record_id)
|
|
134
|
+
return self._run(
|
|
135
|
+
profile,
|
|
136
|
+
lambda session_profile, context: self._attach_context(
|
|
137
|
+
self._request_data_plane(
|
|
138
|
+
context, "GET", f"{self._base_path(app_key)}/{record_id}",
|
|
139
|
+
params={"include_schema": include_schema},
|
|
140
|
+
), profile, session_profile.selected_ws_id,
|
|
141
|
+
),
|
|
142
|
+
tool_name="数据面单条读取",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def data_insert(
|
|
146
|
+
self, *, profile: str, app_key: str, items: list[DataInsertItem], include_schema: bool = True
|
|
147
|
+
) -> JSONObject:
|
|
148
|
+
self._require_app_key(app_key)
|
|
149
|
+
return self._write(profile, app_key, "POST", self._base_path(app_key), {"items": items, "include_schema": include_schema}, "数据面新增")
|
|
150
|
+
|
|
151
|
+
def data_update(
|
|
152
|
+
self, *, profile: str, app_key: str, record_id: str | int, fields: dict, include_schema: bool = True
|
|
153
|
+
) -> JSONObject:
|
|
154
|
+
self._require_app_key(app_key)
|
|
155
|
+
self._require_record_id(record_id)
|
|
156
|
+
return self._write(
|
|
157
|
+
profile, app_key, "POST", f"{self._base_path(app_key)}/{record_id}/update",
|
|
158
|
+
{"fields": fields, "include_schema": include_schema}, "数据面更新",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def data_delete(self, *, profile: str, app_key: str, record_ids: list[str | int]) -> JSONObject:
|
|
162
|
+
self._require_app_key(app_key)
|
|
163
|
+
return self._write(profile, app_key, "POST", self._base_path(app_key) + "/delete", {"record_ids": record_ids}, "数据面删除")
|
|
164
|
+
|
|
165
|
+
def _write(self, profile: str, app_key: str, method: str, path: str, body: JSONObject, tool_name: str) -> JSONObject:
|
|
166
|
+
return self._run(
|
|
167
|
+
profile,
|
|
168
|
+
lambda session_profile, context: self._attach_context(
|
|
169
|
+
self._request_data_plane(context, method, path, json_body=body), profile, session_profile.selected_ws_id,
|
|
170
|
+
),
|
|
171
|
+
tool_name=tool_name,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def _request_data_plane(
|
|
175
|
+
self, context: BackendRequestContext, method: str, path: str, *, params: JSONObject | None = None,
|
|
176
|
+
json_body: JSONValue = None,
|
|
177
|
+
) -> JSONObject:
|
|
178
|
+
raw = self.backend.request(method, context, path, params=params, json_body=json_body, unwrap=False)
|
|
179
|
+
if not isinstance(raw, dict):
|
|
180
|
+
raise QingflowApiError(category="backend", message="数据面返回了非对象响应")
|
|
181
|
+
code = raw.get("code")
|
|
182
|
+
payload = raw.get("data")
|
|
183
|
+
result: JSONObject = dict(payload) if isinstance(payload, dict) else {}
|
|
184
|
+
success = code in (None, 0, 200, 20000) or str(code).lower() in {"success", "ok"}
|
|
185
|
+
result.setdefault("ok", success)
|
|
186
|
+
result.setdefault("status", "success" if success else "failed")
|
|
187
|
+
result.setdefault("hints", [])
|
|
188
|
+
if not success:
|
|
189
|
+
result.setdefault("message", str(raw.get("message") or raw.get("msg") or "数据面请求失败"))
|
|
190
|
+
result.setdefault("backend_code", code)
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
def _export_all(
|
|
194
|
+
self, *, context: BackendRequestContext, profile: str, ws_id: int | None, app_key: str,
|
|
195
|
+
body: JSONObject, has_scope: bool,
|
|
196
|
+
) -> JSONObject:
|
|
197
|
+
probe_body = dict(body)
|
|
198
|
+
probe_body.update({"page": 1, "page_size": 1, "include_schema": True})
|
|
199
|
+
probe = self._request_data_plane(context, "POST", self._base_path(app_key) + "/query", json_body=probe_body)
|
|
200
|
+
if probe.get("ok") is False:
|
|
201
|
+
# 探测请求没有产生文件,保留后端结构化错误,不把失败误报为空数据成功。
|
|
202
|
+
return self._attach_context(probe, profile, ws_id)
|
|
203
|
+
records = probe.get("records") if isinstance(probe.get("records"), dict) else {}
|
|
204
|
+
total = records.get("total") if isinstance(records, dict) else None
|
|
205
|
+
if isinstance(total, int) and total > DATA_UNBOUNDED_ROW_THRESHOLD and not has_scope:
|
|
206
|
+
return self._attach_context({
|
|
207
|
+
"ok": True, "status": "needs_scope", "app_key": app_key, "format": "csv", "row_count": 0,
|
|
208
|
+
"complete": False, "truncated": True, "safe_for_final_conclusion": False, "files": [],
|
|
209
|
+
"hints": probe.get("hints", []),
|
|
210
|
+
"schema": probe.get("schema", {}),
|
|
211
|
+
"field_capabilities": probe.get("field_capabilities", []),
|
|
212
|
+
"scope": {"reported_total": total, "row_threshold": DATA_UNBOUNDED_ROW_THRESHOLD},
|
|
213
|
+
}, profile, ws_id)
|
|
214
|
+
schema = probe.get("schema") if isinstance(probe.get("schema"), dict) else {}
|
|
215
|
+
fields = schema.get("fields") if isinstance(schema, dict) else None
|
|
216
|
+
field_list = fields if isinstance(fields, list) else []
|
|
217
|
+
business_fields = [field for field in field_list if isinstance(field, dict) and field.get("field_id") is not None]
|
|
218
|
+
columns = [(str(field.get("field_id")), str(field.get("title") or field.get("field_id"))) for field in business_fields]
|
|
219
|
+
header = ["record_id"] + [f"{title}__field_{field_id}" for field_id, title in columns]
|
|
220
|
+
run_dir = Path.home() / ".qingflow" / "data-exports" / datetime.now().strftime("%Y%m%d-%H%M%S-%f")
|
|
221
|
+
latest_hints = probe.get("hints", [])
|
|
222
|
+
latest_schema = schema
|
|
223
|
+
latest_capabilities = probe.get("field_capabilities", [])
|
|
224
|
+
|
|
225
|
+
def fetch_page(page: int, page_size: int) -> JSONObject:
|
|
226
|
+
nonlocal latest_hints, latest_schema, latest_capabilities
|
|
227
|
+
request_body = dict(body)
|
|
228
|
+
# 每页都请求 schema,使最后一页的后端原始 hints 同时准确表达完整性和字段能力。
|
|
229
|
+
request_body.update({"page": page, "page_size": page_size, "include_schema": True})
|
|
230
|
+
response = self._request_data_plane(
|
|
231
|
+
context, "POST", self._base_path(app_key) + "/query", json_body=request_body
|
|
232
|
+
)
|
|
233
|
+
if response.get("ok") is False:
|
|
234
|
+
raise CsvPageFailure(response)
|
|
235
|
+
latest_hints = response.get("hints", [])
|
|
236
|
+
if isinstance(response.get("schema"), dict):
|
|
237
|
+
latest_schema = response["schema"]
|
|
238
|
+
if isinstance(response.get("field_capabilities"), list):
|
|
239
|
+
latest_capabilities = response["field_capabilities"]
|
|
240
|
+
return response
|
|
241
|
+
|
|
242
|
+
def project_row(record: JSONObject) -> list[str]:
|
|
243
|
+
values = record.get("fields") if isinstance(record.get("fields"), dict) else record
|
|
244
|
+
return [str(record.get("record_id") or "")] + [csv_cell(values.get(field_id)) for field_id, _ in columns]
|
|
245
|
+
|
|
246
|
+
exported = export_csv_pages(
|
|
247
|
+
fetch_page=fetch_page, header=header, project_row=project_row, output_dir=run_dir,
|
|
248
|
+
file_prefix="records", page_size=DATA_PAGE_SIZE, shard_rows=DATA_CSV_SHARD_ROWS,
|
|
249
|
+
time_budget_seconds=DATA_CSV_TIME_BUDGET_SECONDS,
|
|
250
|
+
)
|
|
251
|
+
page_error = exported.pop("page_error", None)
|
|
252
|
+
result: JSONObject = {
|
|
253
|
+
"ok": True, "status": "success" if exported["complete"] else "partial", "app_key": app_key,
|
|
254
|
+
"format": "csv", "local_dir": str(run_dir), "fields": business_fields,
|
|
255
|
+
"schema": latest_schema, "field_capabilities": latest_capabilities, "hints": latest_hints,
|
|
256
|
+
**exported,
|
|
257
|
+
}
|
|
258
|
+
if isinstance(page_error, dict):
|
|
259
|
+
# 后续页失败时保留已落盘分片,同时把后端稳定错误字段原样带回。
|
|
260
|
+
result["ok"] = False
|
|
261
|
+
for key in ("error_code", "message", "backend_code", "write_executed", "retry_safe", "field_errors"):
|
|
262
|
+
if key in page_error:
|
|
263
|
+
result[key] = page_error[key]
|
|
264
|
+
if "hints" in page_error:
|
|
265
|
+
result["hints"] = page_error["hints"]
|
|
266
|
+
return self._attach_context(result, profile, ws_id)
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _base_path(app_key: str) -> str:
|
|
270
|
+
return f"/app/{app_key}/data/v1/records"
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _attach_context(result: JSONObject, profile: str, ws_id: int | None) -> JSONObject:
|
|
274
|
+
return {"profile": profile, "ws_id": ws_id, **result}
|
|
275
|
+
|
|
276
|
+
@staticmethod
|
|
277
|
+
def _require_app_key(app_key: str) -> None:
|
|
278
|
+
if not str(app_key or "").strip():
|
|
279
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
280
|
+
|
|
281
|
+
@staticmethod
|
|
282
|
+
def _require_record_id(record_id: str | int) -> None:
|
|
283
|
+
if not str(record_id or "").strip():
|
|
284
|
+
raise_tool_error(QingflowApiError.config_error("record_id is required"))
|
|
@@ -6,18 +6,30 @@ 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, resolve_resource_web_origin
|
|
12
13
|
from .base import ToolBase
|
|
13
14
|
|
|
14
15
|
LOGIN_LINK_DESCRIPTION = (
|
|
15
16
|
"轻流页面入口:查看页面、继续编辑、处理业务、查看结果或进入工作区(open/view/visit page in browser,login)。"
|
|
16
17
|
"从 AI 对话进入页面时,使用当前 MCP 身份和工作区生成短时(约 5 分钟)一次性免登录链接。"
|
|
17
18
|
"redirect_path 可选:传已确认的站内业务路径,可带 query/hash;省略进入首页,不接受外部地址或认证入口。"
|
|
18
|
-
"
|
|
19
|
+
"用户指定页面但路径未知时先定位,不猜路径或静默改为首页。"
|
|
19
20
|
"返回 url 和毫秒整数 expires_at;必要安全验证及原有页面权限仍然生效。"
|
|
20
|
-
"
|
|
21
|
+
"创建、修改或安装轻流资源后,最终回复主动提供主要结果的可点击页面入口,无需等待用户再次索要;"
|
|
22
|
+
"采用“完成结果 + 查看入口”,文案使用“在轻流中查看应用/应用包/视图”,不默认称为“继续编辑”。"
|
|
23
|
+
"安装方案默认提供应用包入口;直接创建或修改应用、视图时提供对应入口;用户明确要求多个主要对象时分别提供。"
|
|
24
|
+
"使用业务结果中的稳定 `uri`,按 URI 去重,不为全部子资源预签票据;主要对象没有可靠 URI 时说明限制,不猜地址或替换为不相关页面。"
|
|
25
|
+
"部分成功时仅交付已确认成功且可定位的主要结果,保留失败或未验证说明;"
|
|
26
|
+
"纯数据问答、中间读取、失败资源和删除操作不自动签发,用户明确要求不提供链接时遵从。"
|
|
27
|
+
"目标浏览器登录态未知时,临近交付用资源 URI 的同源 pathname 为每个实际展示的入口分别签发,使用返回的完整 URL,不额外追问登录态。"
|
|
28
|
+
"只有已有用户确认同一浏览器、网页域名、账号和工作区已登录且无失效信号的事实时,才直接交付稳定 URI;"
|
|
29
|
+
"调用过本工具或 Agent 自动化浏览器已登录不等于用户浏览器已登录,已消费链接不可复用。"
|
|
30
|
+
"用户未要求打开时不提前打开或消费链接;要求打开时才用浏览器打开返回的完整链接。"
|
|
31
|
+
"签发失败时说明免登录链接未生成,提供标注“需登录”的稳定 URI 作为备用入口,不重做业务操作。"
|
|
32
|
+
"机器结果继续保留稳定 URI,临时登录链接仅用于面向用户交付。"
|
|
21
33
|
)
|
|
22
34
|
|
|
23
35
|
|
|
@@ -70,15 +82,31 @@ class LoginLinkTools(ToolBase):
|
|
|
70
82
|
"""执行工具方法逻辑。"""
|
|
71
83
|
|
|
72
84
|
def runner(session_profile, context):
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
requested_target = redirect_path or ""
|
|
86
|
+
if requested_target.startswith("/resources"):
|
|
87
|
+
target = requested_target
|
|
88
|
+
try:
|
|
89
|
+
resource = parse_resource_path(target)
|
|
90
|
+
except ValueError:
|
|
91
|
+
raise QingflowApiError(
|
|
92
|
+
category="validation",
|
|
93
|
+
message="redirect_path 必须是合法的稳定资源路径",
|
|
94
|
+
) from None
|
|
95
|
+
if resource.ws_id != session_profile.selected_ws_id:
|
|
96
|
+
raise QingflowApiError(
|
|
97
|
+
category="validation",
|
|
98
|
+
message="redirect_path 的资源工作区必须与当前选中工作区一致",
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
target = validate_redirect_path(requested_target)
|
|
102
|
+
web_base_url = resolve_resource_web_origin()
|
|
75
103
|
result = self.backend.request(
|
|
76
104
|
"POST",
|
|
77
105
|
context,
|
|
78
106
|
"/mcp/auth/login-link",
|
|
79
107
|
json_body={"wsId": session_profile.selected_ws_id},
|
|
80
108
|
)
|
|
81
|
-
#
|
|
109
|
+
# 后端仅提供票据;CLI 使用 QINGFLOW_BASE_URL 对应网页环境,MCP 使用独立网页配置。
|
|
82
110
|
try:
|
|
83
111
|
source_url = result.get("url") if isinstance(result, dict) else None
|
|
84
112
|
if not isinstance(source_url, str):
|