@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 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.1014
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.1014 qingflow-app-builder-mcp
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-builder-mcp",
3
- "version": "0.2.0-beta.1014",
3
+ "version": "0.2.0-beta.1016",
4
4
  "description": "Builder MCP for Qingflow app/package/system design and staged solution workflows.",
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 = "0.2.0b1014"
7
+ version = "0.2.0b1016"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  __all__ = ["__version__"]
7
7
 
8
- _FALLBACK_VERSION = "0.2.0b1014"
8
+ _FALLBACK_VERSION = "0.2.0b1016"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -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":
@@ -666,7 +666,6 @@ def _trim_import_status_payload(payload: JSONObject) -> None:
666
666
  "total_rows",
667
667
  "success_rows",
668
668
  "failed_rows",
669
- "error_file_urls",
670
669
  "operate_time",
671
670
  "operate_user",
672
671
  "verification",
@@ -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
- field
768
- for field in self._record_tools._schema_fields_for_mode(
769
- profile,
770
- context,
771
- app_key,
772
- index,
773
- schema_mode="browse",
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": _normalize_optional_text(matched_record.get("processStatus")) or "unknown",
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: