@josephyan/qingflow-app-builder-mcp 0.2.0-beta.1014 → 0.2.0-beta.1016
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 +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/cli/formatters.py +7 -0
- package/src/qingflow_mcp/errors.py +2 -2
- package/src/qingflow_mcp/response_trim.py +0 -1
- package/src/qingflow_mcp/tools/export_tools.py +154 -13
- package/src/qingflow_mcp/tools/import_tools.py +42 -2
- package/src/qingflow_mcp/tools/record_tools.py +2 -0
package/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Install:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.
|
|
6
|
+
npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.1016
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.
|
|
12
|
+
npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.1016 qingflow-app-builder-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -407,6 +407,13 @@ def _format_import_status(result: dict[str, Any]) -> str:
|
|
|
407
407
|
f"Failed Rows: {result.get('failed') or 0}",
|
|
408
408
|
f"Progress: {result.get('progress') or '-'}",
|
|
409
409
|
]
|
|
410
|
+
if result.get("process_status") not in (None, ""):
|
|
411
|
+
lines.append(f"Process Status: {result.get('process_status')}")
|
|
412
|
+
error_file_urls = result.get("error_file_urls") if isinstance(result.get("error_file_urls"), list) else []
|
|
413
|
+
if error_file_urls:
|
|
414
|
+
lines.append("Error Files:")
|
|
415
|
+
for url in error_file_urls:
|
|
416
|
+
lines.append(f"- {url}")
|
|
410
417
|
_append_warnings(lines, result.get("warnings"))
|
|
411
418
|
_append_verification(lines, result.get("verification"))
|
|
412
419
|
return "\n".join(lines) + "\n"
|
|
@@ -54,8 +54,8 @@ class QingflowApiError(Exception):
|
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
@classmethod
|
|
57
|
-
def config_error(cls, message: str) -> "QingflowApiError":
|
|
58
|
-
return cls(category="config", message=message)
|
|
57
|
+
def config_error(cls, message: str, *, details: JSONObject | None = None) -> "QingflowApiError":
|
|
58
|
+
return cls(category="config", message=message, details=details)
|
|
59
59
|
|
|
60
60
|
@classmethod
|
|
61
61
|
def not_supported(cls, message: str) -> "QingflowApiError":
|
|
@@ -19,6 +19,7 @@ from .base import ToolBase, tool_cn_name
|
|
|
19
19
|
from .record_tools import (
|
|
20
20
|
AccessibleViewRoute,
|
|
21
21
|
DEFAULT_LIST_PAGE_SIZE,
|
|
22
|
+
FormField,
|
|
22
23
|
LAYOUT_ONLY_QUE_TYPES,
|
|
23
24
|
RecordTools,
|
|
24
25
|
_normalize_public_column_selectors,
|
|
@@ -191,6 +192,8 @@ class ExportTools(ToolBase):
|
|
|
191
192
|
filter_bean = self._build_export_filter_bean(
|
|
192
193
|
resolved_view,
|
|
193
194
|
selected_record_ids=effective_record_ids,
|
|
195
|
+
order_by=normalized_order_by,
|
|
196
|
+
row_scope=row_scope,
|
|
194
197
|
include_workflow_log=include_workflow_log,
|
|
195
198
|
)
|
|
196
199
|
started_at = _utc_now().replace(microsecond=0).isoformat()
|
|
@@ -731,13 +734,31 @@ class ExportTools(ToolBase):
|
|
|
731
734
|
resolved_view: AccessibleViewRoute,
|
|
732
735
|
*,
|
|
733
736
|
selected_record_ids: list[int],
|
|
737
|
+
order_by: list[JSONObject],
|
|
738
|
+
row_scope: str,
|
|
734
739
|
include_workflow_log: bool,
|
|
735
740
|
) -> JSONObject:
|
|
736
741
|
filter_payload: JSONObject = {}
|
|
737
742
|
if resolved_view.kind == "system" and resolved_view.list_type is not None:
|
|
738
743
|
filter_payload["type"] = resolved_view.list_type
|
|
744
|
+
elif resolved_view.kind == "custom":
|
|
745
|
+
# Custom-view native export later flows through shared data-export code that
|
|
746
|
+
# expects a list semantics integer. Frontend export treats custom views as
|
|
747
|
+
# creator-all style export, so mirror LIST_CREATOR_ALL here.
|
|
748
|
+
filter_payload["type"] = DEFAULT_RECORD_LIST_TYPE
|
|
739
749
|
if selected_record_ids:
|
|
740
750
|
filter_payload["applyIds"] = selected_record_ids
|
|
751
|
+
if row_scope == "queried" and order_by:
|
|
752
|
+
normalized_sorts = [
|
|
753
|
+
{
|
|
754
|
+
"queId": field_id,
|
|
755
|
+
"isAscend": str(item.get("direction") or "asc").strip().lower() != "desc",
|
|
756
|
+
}
|
|
757
|
+
for item in order_by
|
|
758
|
+
if isinstance(item, dict) and (field_id := _coerce_int(item.get("field_id"))) is not None
|
|
759
|
+
]
|
|
760
|
+
if normalized_sorts:
|
|
761
|
+
filter_payload["sorts"] = normalized_sorts
|
|
741
762
|
return {
|
|
742
763
|
"filter": filter_payload,
|
|
743
764
|
# Backend export code later auto-unboxes this field to primitive boolean.
|
|
@@ -763,18 +784,14 @@ class ExportTools(ToolBase):
|
|
|
763
784
|
)
|
|
764
785
|
index = browse_scope["index"]
|
|
765
786
|
visible_question_ids = cast(set[int], browse_scope.get("visible_question_ids") or set())
|
|
766
|
-
ordered_visible_fields =
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
resolved_view=resolved_view,
|
|
775
|
-
)
|
|
776
|
-
if field.que_id in visible_question_ids and field.que_type not in LAYOUT_ONLY_QUE_TYPES
|
|
777
|
-
]
|
|
787
|
+
ordered_visible_fields, warnings = self._resolve_exportable_fields(
|
|
788
|
+
profile=profile,
|
|
789
|
+
context=context,
|
|
790
|
+
app_key=app_key,
|
|
791
|
+
resolved_view=resolved_view,
|
|
792
|
+
index=index,
|
|
793
|
+
visible_question_ids=visible_question_ids,
|
|
794
|
+
)
|
|
778
795
|
if not ordered_visible_fields:
|
|
779
796
|
ordered_visible_fields = [
|
|
780
797
|
field
|
|
@@ -805,7 +822,6 @@ class ExportTools(ToolBase):
|
|
|
805
822
|
else:
|
|
806
823
|
selected_fields = ordered_visible_fields
|
|
807
824
|
question_export_config_list: list[JSONObject] = []
|
|
808
|
-
warnings: list[JSONObject] = []
|
|
809
825
|
for field in selected_fields:
|
|
810
826
|
if field.que_type is None:
|
|
811
827
|
warnings.append(
|
|
@@ -830,6 +846,79 @@ class ExportTools(ToolBase):
|
|
|
830
846
|
)
|
|
831
847
|
return {"questionExportConfigList": question_export_config_list}, warnings
|
|
832
848
|
|
|
849
|
+
def _resolve_exportable_fields(
|
|
850
|
+
self,
|
|
851
|
+
*,
|
|
852
|
+
profile: str,
|
|
853
|
+
context,
|
|
854
|
+
app_key: str,
|
|
855
|
+
resolved_view: AccessibleViewRoute,
|
|
856
|
+
index,
|
|
857
|
+
visible_question_ids: set[int],
|
|
858
|
+
) -> tuple[list[FormField], list[JSONObject]]: # type: ignore[no-untyped-def]
|
|
859
|
+
warnings: list[JSONObject] = []
|
|
860
|
+
if resolved_view.kind == "custom" and resolved_view.view_selection is not None:
|
|
861
|
+
custom_fields = self._resolve_custom_view_exportable_fields(
|
|
862
|
+
profile=profile,
|
|
863
|
+
context=context,
|
|
864
|
+
app_key=app_key,
|
|
865
|
+
view_key=resolved_view.view_selection.view_key,
|
|
866
|
+
index=index,
|
|
867
|
+
visible_question_ids=visible_question_ids,
|
|
868
|
+
)
|
|
869
|
+
if custom_fields is not None:
|
|
870
|
+
return custom_fields, warnings
|
|
871
|
+
warnings.append(
|
|
872
|
+
{
|
|
873
|
+
"code": "EXPORT_VIEW_CONFIG_PARTIAL",
|
|
874
|
+
"message": "custom view export fields fell back to schema order because viewConfig could not provide exportable field entries",
|
|
875
|
+
}
|
|
876
|
+
)
|
|
877
|
+
ordered_visible_fields = [
|
|
878
|
+
field
|
|
879
|
+
for field in self._record_tools._schema_fields_for_mode(
|
|
880
|
+
profile,
|
|
881
|
+
context,
|
|
882
|
+
app_key,
|
|
883
|
+
index,
|
|
884
|
+
schema_mode="browse",
|
|
885
|
+
resolved_view=resolved_view,
|
|
886
|
+
)
|
|
887
|
+
if field.que_id in visible_question_ids and field.que_type not in LAYOUT_ONLY_QUE_TYPES
|
|
888
|
+
]
|
|
889
|
+
return ordered_visible_fields, warnings
|
|
890
|
+
|
|
891
|
+
def _resolve_custom_view_exportable_fields(
|
|
892
|
+
self,
|
|
893
|
+
*,
|
|
894
|
+
profile: str,
|
|
895
|
+
context,
|
|
896
|
+
app_key: str,
|
|
897
|
+
view_key: str,
|
|
898
|
+
index,
|
|
899
|
+
visible_question_ids: set[int],
|
|
900
|
+
) -> list[FormField] | None: # type: ignore[no-untyped-def]
|
|
901
|
+
view_config = self._record_tools._get_view_config(profile, context, view_key)
|
|
902
|
+
if not isinstance(view_config, dict):
|
|
903
|
+
return None
|
|
904
|
+
entries = _extract_export_view_question_entries(view_config.get("viewgraphQuestions"))
|
|
905
|
+
if not entries:
|
|
906
|
+
return []
|
|
907
|
+
ordered_fields: list[FormField] = []
|
|
908
|
+
seen: set[int] = set()
|
|
909
|
+
for entry in entries:
|
|
910
|
+
if not bool(entry.get("downloadable", True)):
|
|
911
|
+
continue
|
|
912
|
+
field_id = _coerce_int(entry.get("field_id"))
|
|
913
|
+
if field_id is None or field_id in seen:
|
|
914
|
+
continue
|
|
915
|
+
field = cast(FormField | None, cast(Any, index).by_id.get(str(field_id)))
|
|
916
|
+
if field is None or field.que_type in LAYOUT_ONLY_QUE_TYPES:
|
|
917
|
+
continue
|
|
918
|
+
ordered_fields.append(field)
|
|
919
|
+
seen.add(field_id)
|
|
920
|
+
return ordered_fields
|
|
921
|
+
|
|
833
922
|
def _estimate_export_result_amount(
|
|
834
923
|
self,
|
|
835
924
|
context,
|
|
@@ -1241,6 +1330,58 @@ def _coerce_int(value: Any) -> int | None:
|
|
|
1241
1330
|
return None
|
|
1242
1331
|
|
|
1243
1332
|
|
|
1333
|
+
def _coerce_positive_int(value: Any) -> int | None:
|
|
1334
|
+
parsed = _coerce_int(value)
|
|
1335
|
+
if parsed is None or parsed <= 0:
|
|
1336
|
+
return None
|
|
1337
|
+
return parsed
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def _extract_export_view_question_entries(questions: Any) -> list[JSONObject]:
|
|
1341
|
+
if not isinstance(questions, list):
|
|
1342
|
+
return []
|
|
1343
|
+
entries: list[JSONObject] = []
|
|
1344
|
+
fallback_order = 0
|
|
1345
|
+
|
|
1346
|
+
def walk(nodes: Any) -> None:
|
|
1347
|
+
nonlocal fallback_order
|
|
1348
|
+
if not isinstance(nodes, list):
|
|
1349
|
+
return
|
|
1350
|
+
for item in nodes:
|
|
1351
|
+
if not isinstance(item, dict):
|
|
1352
|
+
continue
|
|
1353
|
+
children: list[Any] = []
|
|
1354
|
+
for child_key in ("innerQues", "subQues", "innerQuestions", "subQuestions"):
|
|
1355
|
+
child_value = item.get(child_key)
|
|
1356
|
+
if isinstance(child_value, list) and child_value:
|
|
1357
|
+
children.extend(child_value)
|
|
1358
|
+
if children:
|
|
1359
|
+
walk(children)
|
|
1360
|
+
continue
|
|
1361
|
+
field_id = _coerce_int(item.get("queId"))
|
|
1362
|
+
if field_id is None:
|
|
1363
|
+
continue
|
|
1364
|
+
fallback_order += 1
|
|
1365
|
+
downloadable_raw = item.get("beingDownload")
|
|
1366
|
+
entries.append(
|
|
1367
|
+
{
|
|
1368
|
+
"field_id": field_id,
|
|
1369
|
+
"name": str(item.get("queTitle") or "").strip(),
|
|
1370
|
+
"display_order": _coerce_positive_int(item.get("displayOrdinal")) or fallback_order,
|
|
1371
|
+
"downloadable": bool(downloadable_raw) if downloadable_raw is not None else True,
|
|
1372
|
+
}
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
walk(questions)
|
|
1376
|
+
return sorted(
|
|
1377
|
+
entries,
|
|
1378
|
+
key=lambda entry: (
|
|
1379
|
+
_coerce_positive_int(entry.get("display_order")) if _coerce_positive_int(entry.get("display_order")) is not None else 10**9,
|
|
1380
|
+
str(entry.get("name") or ""),
|
|
1381
|
+
),
|
|
1382
|
+
)
|
|
1383
|
+
|
|
1384
|
+
|
|
1244
1385
|
def _normalize_export_columns(columns: list[JSONObject | int]) -> list[int]:
|
|
1245
1386
|
normalized: list[int] = []
|
|
1246
1387
|
for field_id in _normalize_public_column_selectors(columns):
|
|
@@ -37,6 +37,13 @@ SAFE_REPAIRS = {
|
|
|
37
37
|
"normalize_url_cells",
|
|
38
38
|
}
|
|
39
39
|
EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
40
|
+
IMPORT_STATUS_BY_PROCESS_STATUS = {
|
|
41
|
+
1: "queued",
|
|
42
|
+
2: "running",
|
|
43
|
+
3: "succeeded",
|
|
44
|
+
4: "failed",
|
|
45
|
+
5: "partially_failed",
|
|
46
|
+
}
|
|
40
47
|
|
|
41
48
|
|
|
42
49
|
class ImportTools(ToolBase):
|
|
@@ -866,13 +873,26 @@ class ImportTools(ToolBase):
|
|
|
866
873
|
"process_id_str": normalized_process,
|
|
867
874
|
},
|
|
868
875
|
)
|
|
876
|
+
raw_process_status = matched_record.get("processStatus")
|
|
869
877
|
total_rows = _coerce_int(matched_record.get("totalNumber") or matched_record.get("total_rows"))
|
|
870
878
|
success_rows = _coerce_int(matched_record.get("successNum") or matched_record.get("success_rows"))
|
|
871
879
|
failed_rows = _coerce_int(matched_record.get("errorNum") or matched_record.get("failed_rows"))
|
|
872
880
|
progress = _coerce_int(matched_record.get("importPercentage") or matched_record.get("progress"))
|
|
881
|
+
normalized_status = _normalize_import_status(raw_process_status)
|
|
882
|
+
warnings: list[dict[str, str]] = []
|
|
883
|
+
if normalized_status in {"succeeded", "failed", "partially_failed"} and all(
|
|
884
|
+
value is None for value in (total_rows, success_rows, failed_rows)
|
|
885
|
+
):
|
|
886
|
+
warnings.append(
|
|
887
|
+
{
|
|
888
|
+
"code": "IMPORT_STATUS_COUNTERS_MISSING",
|
|
889
|
+
"message": "backend import history returned a terminal process status without row counters",
|
|
890
|
+
}
|
|
891
|
+
)
|
|
873
892
|
return {
|
|
874
893
|
"ok": True,
|
|
875
|
-
"status":
|
|
894
|
+
"status": normalized_status,
|
|
895
|
+
"process_status": _coerce_int(raw_process_status),
|
|
876
896
|
"app_key": resolved_app_key,
|
|
877
897
|
"import_id": normalized_import_id or (local_job.get("import_id") if isinstance(local_job, dict) else None),
|
|
878
898
|
"process_id_str": normalized_process,
|
|
@@ -885,7 +905,7 @@ class ImportTools(ToolBase):
|
|
|
885
905
|
"error_file_urls": _normalize_error_file_urls(matched_record.get("errorFileUrls")),
|
|
886
906
|
"operate_time": matched_record.get("operateTime"),
|
|
887
907
|
"operate_user": matched_record.get("operateUser"),
|
|
888
|
-
"warnings":
|
|
908
|
+
"warnings": warnings,
|
|
889
909
|
"verification": {
|
|
890
910
|
"status_lookup_completed": True,
|
|
891
911
|
"matched_by": matched_by,
|
|
@@ -2217,6 +2237,26 @@ def _coerce_int(value: Any) -> int | None:
|
|
|
2217
2237
|
return None
|
|
2218
2238
|
|
|
2219
2239
|
|
|
2240
|
+
def _normalize_import_status(value: Any) -> str:
|
|
2241
|
+
status_code = _coerce_int(value)
|
|
2242
|
+
if status_code is not None:
|
|
2243
|
+
return IMPORT_STATUS_BY_PROCESS_STATUS.get(status_code, "unknown")
|
|
2244
|
+
text = str(value or "").strip().lower()
|
|
2245
|
+
if text in {"queued", "running", "succeeded", "failed", "partially_failed", "unknown"}:
|
|
2246
|
+
return text
|
|
2247
|
+
if text in {"line_up", "lineup"}:
|
|
2248
|
+
return "queued"
|
|
2249
|
+
if text in {"execute", "executing", "processing"}:
|
|
2250
|
+
return "running"
|
|
2251
|
+
if text in {"success", "completed"}:
|
|
2252
|
+
return "succeeded"
|
|
2253
|
+
if text in {"partly_fail", "partial_fail", "partially_fail", "partial_failed"}:
|
|
2254
|
+
return "partially_failed"
|
|
2255
|
+
if text in {"fail", "error"}:
|
|
2256
|
+
return "failed"
|
|
2257
|
+
return "unknown"
|
|
2258
|
+
|
|
2259
|
+
|
|
2220
2260
|
def _normalize_error_file_urls(value: Any) -> list[str]:
|
|
2221
2261
|
if isinstance(value, list):
|
|
2222
2262
|
return [str(item).strip() for item in value if str(item).strip()]
|
|
@@ -6890,6 +6890,8 @@ class RecordTools(ToolBase):
|
|
|
6890
6890
|
isinstance(payload.get("viewgraphLimit"), list)
|
|
6891
6891
|
or isinstance(payload.get("viewConfig"), dict)
|
|
6892
6892
|
or isinstance(payload.get("viewgraphConfig"), dict)
|
|
6893
|
+
or isinstance(payload.get("viewgraphQuestions"), list)
|
|
6894
|
+
or isinstance(payload.get("viewgraphQueIds"), list)
|
|
6893
6895
|
):
|
|
6894
6896
|
config = payload
|
|
6895
6897
|
else:
|