@qingflow-tech/qingflow-app-builder-mcp 1.0.10 → 1.0.12

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.
Files changed (60) hide show
  1. package/README.md +6 -3
  2. package/docs/local-agent-install.md +54 -3
  3. package/entry_point.py +1 -1
  4. package/npm/bin/qingflow-skills.mjs +5 -0
  5. package/npm/lib/runtime.mjs +304 -13
  6. package/npm/scripts/postinstall.mjs +1 -5
  7. package/package.json +3 -2
  8. package/pyproject.toml +1 -1
  9. package/skills/qingflow-app-builder/SKILL.md +12 -12
  10. package/skills/qingflow-app-builder/references/create-app.md +3 -3
  11. package/skills/qingflow-app-builder/references/environments.md +1 -1
  12. package/skills/qingflow-app-builder/references/gotchas.md +1 -1
  13. package/skills/qingflow-app-builder/references/public-surface-sync.md +75 -0
  14. package/skills/qingflow-app-builder/references/tool-selection.md +6 -5
  15. package/skills/qingflow-app-builder/references/update-views.md +1 -1
  16. package/skills/qingflow-app-builder-code-integrations/SKILL.md +3 -3
  17. package/skills/qingflow-app-builder-code-integrations/references/code-block.md +1 -1
  18. package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +1 -1
  19. package/src/qingflow_mcp/__main__.py +6 -2
  20. package/src/qingflow_mcp/builder_facade/models.py +41 -2
  21. package/src/qingflow_mcp/builder_facade/service.py +2743 -423
  22. package/src/qingflow_mcp/cli/commands/app.py +3 -16
  23. package/src/qingflow_mcp/cli/commands/builder.py +30 -4
  24. package/src/qingflow_mcp/cli/commands/exports.py +2 -2
  25. package/src/qingflow_mcp/cli/commands/imports.py +1 -1
  26. package/src/qingflow_mcp/cli/commands/record.py +54 -11
  27. package/src/qingflow_mcp/cli/context.py +0 -3
  28. package/src/qingflow_mcp/cli/formatters.py +238 -8
  29. package/src/qingflow_mcp/cli/main.py +47 -3
  30. package/src/qingflow_mcp/errors.py +43 -2
  31. package/src/qingflow_mcp/public_surface.py +24 -16
  32. package/src/qingflow_mcp/response_trim.py +119 -12
  33. package/src/qingflow_mcp/server.py +17 -14
  34. package/src/qingflow_mcp/server_app_builder.py +29 -7
  35. package/src/qingflow_mcp/server_app_user.py +23 -24
  36. package/src/qingflow_mcp/solution/compiler/icon_utils.py +294 -0
  37. package/src/qingflow_mcp/solution/executor.py +112 -15
  38. package/src/qingflow_mcp/tools/ai_builder_tools.py +497 -65
  39. package/src/qingflow_mcp/tools/app_tools.py +237 -51
  40. package/src/qingflow_mcp/tools/approval_tools.py +196 -34
  41. package/src/qingflow_mcp/tools/auth_tools.py +92 -16
  42. package/src/qingflow_mcp/tools/code_block_tools.py +296 -39
  43. package/src/qingflow_mcp/tools/custom_button_tools.py +64 -10
  44. package/src/qingflow_mcp/tools/directory_tools.py +236 -72
  45. package/src/qingflow_mcp/tools/export_tools.py +230 -33
  46. package/src/qingflow_mcp/tools/file_tools.py +7 -3
  47. package/src/qingflow_mcp/tools/import_tools.py +293 -40
  48. package/src/qingflow_mcp/tools/navigation_tools.py +91 -12
  49. package/src/qingflow_mcp/tools/package_tools.py +134 -8
  50. package/src/qingflow_mcp/tools/portal_tools.py +39 -3
  51. package/src/qingflow_mcp/tools/qingbi_report_tools.py +116 -7
  52. package/src/qingflow_mcp/tools/record_tools.py +2305 -442
  53. package/src/qingflow_mcp/tools/resource_read_tools.py +191 -39
  54. package/src/qingflow_mcp/tools/role_tools.py +80 -9
  55. package/src/qingflow_mcp/tools/solution_tools.py +57 -15
  56. package/src/qingflow_mcp/tools/task_context_tools.py +569 -119
  57. package/src/qingflow_mcp/tools/task_tools.py +113 -29
  58. package/src/qingflow_mcp/tools/view_tools.py +106 -3
  59. package/src/qingflow_mcp/tools/workflow_tools.py +17 -1
  60. package/src/qingflow_mcp/tools/workspace_tools.py +71 -3
@@ -13,7 +13,7 @@ from mcp.server.fastmcp import FastMCP
13
13
 
14
14
  from ..backend_client import BackendRequestContext
15
15
  from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE
16
- from ..errors import QingflowApiError
16
+ from ..errors import QingflowApiError, backend_code_int, is_auth_like_error, message_looks_like_invalid_token
17
17
  from ..export_store import ExportJobStore
18
18
  from ..json_types import JSONObject
19
19
  from .base import ToolBase, tool_cn_name
@@ -23,6 +23,8 @@ from .record_tools import (
23
23
  FormField,
24
24
  LAYOUT_ONLY_QUE_TYPES,
25
25
  RecordTools,
26
+ _build_top_level_field_index,
27
+ _normalize_data_list_base_info_schema,
26
28
  _normalize_public_column_selectors,
27
29
  )
28
30
 
@@ -59,7 +61,7 @@ class ExportTools(ToolBase):
59
61
  def record_export_start(
60
62
  profile: str = DEFAULT_PROFILE,
61
63
  app_key: str = "",
62
- view_id: str = "system:all",
64
+ view_id: str = "",
63
65
  columns: list[JSONObject | int] | None = None,
64
66
  where: list[JSONObject] | None = None,
65
67
  order_by: list[JSONObject] | None = None,
@@ -103,7 +105,7 @@ class ExportTools(ToolBase):
103
105
  def record_export_direct(
104
106
  profile: str = DEFAULT_PROFILE,
105
107
  app_key: str = "",
106
- view_id: str = "system:all",
108
+ view_id: str = "",
107
109
  columns: list[JSONObject | int] | None = None,
108
110
  where: list[JSONObject] | None = None,
109
111
  order_by: list[JSONObject] | None = None,
@@ -131,7 +133,7 @@ class ExportTools(ToolBase):
131
133
  *,
132
134
  profile: str = DEFAULT_PROFILE,
133
135
  app_key: str,
134
- view_id: str = "system:all",
136
+ view_id: str = "",
135
137
  columns: list[JSONObject | int] | None = None,
136
138
  where: list[JSONObject] | None = None,
137
139
  order_by: list[JSONObject] | None = None,
@@ -139,7 +141,7 @@ class ExportTools(ToolBase):
139
141
  include_workflow_log: bool = False,
140
142
  ) -> dict[str, Any]:
141
143
  normalized_app_key = str(app_key or "").strip()
142
- normalized_view_id = str(view_id or "").strip() or "system:all"
144
+ normalized_view_id = str(view_id or "").strip()
143
145
  normalized_columns = _normalize_export_columns(columns or [])
144
146
  normalized_where = self._record_tools._normalize_record_list_where(where or [])
145
147
  normalized_order_by = self._record_tools._normalize_record_list_order_by(order_by or [])
@@ -150,6 +152,12 @@ class ExportTools(ToolBase):
150
152
  message="app_key is required",
151
153
  extra={"view_id": normalized_view_id, "status": "failed"},
152
154
  )
155
+ if not normalized_view_id:
156
+ return self._failed_export_result(
157
+ error_code="EXPORT_VIEW_REQUIRED",
158
+ message="view_id is required; call app_get first and pass accessible_views[].view_id or use the view_id from the frontend URL",
159
+ extra={"app_key": normalized_app_key, "view_id": normalized_view_id, "status": "failed"},
160
+ )
153
161
 
154
162
  def runner(session_profile, context):
155
163
  resolved_view, compatibility_warnings = self._record_tools._resolve_accessible_view_route(
@@ -160,7 +168,7 @@ class ExportTools(ToolBase):
160
168
  list_type=None,
161
169
  view_key=None,
162
170
  view_name=None,
163
- allow_default=True,
171
+ allow_default=False,
164
172
  )
165
173
  export_config, export_config_warnings = self._build_export_config(
166
174
  profile=profile,
@@ -238,6 +246,8 @@ class ExportTools(ToolBase):
238
246
  return {
239
247
  "ok": True,
240
248
  "status": "accepted",
249
+ "export_executed": True,
250
+ "safe_to_retry_export": False,
241
251
  "app_key": normalized_app_key,
242
252
  "view_id": resolved_view.view_id,
243
253
  "export_handle": export_handle,
@@ -404,13 +414,17 @@ class ExportTools(ToolBase):
404
414
  "verification": snapshot.get("verification") or {},
405
415
  },
406
416
  )
407
- downloaded_files = self._download_export_files(
417
+ downloaded_files, download_warnings = self._download_export_files(
408
418
  file_infos=file_infos,
409
419
  download_to_path=download_to_path,
410
420
  default_directory=None,
411
421
  app_key=str(local_job.get("app_key") or ""),
412
422
  view_id=str(local_job.get("view_id") or ""),
413
423
  )
424
+ warnings = [
425
+ *cast(list[JSONObject], snapshot.get("warnings") or []),
426
+ *download_warnings,
427
+ ]
414
428
  return {
415
429
  "ok": True,
416
430
  "status": "succeeded",
@@ -429,7 +443,7 @@ class ExportTools(ToolBase):
429
443
  "file_urls": snapshot.get("file_urls") or [],
430
444
  "file_names": snapshot.get("file_names") or [],
431
445
  "downloaded_files": downloaded_files,
432
- "warnings": snapshot.get("warnings") or [],
446
+ "warnings": warnings,
433
447
  "verification": snapshot.get("verification") or {},
434
448
  "request_route": self.backend.describe_route(lookup_context),
435
449
  }
@@ -449,7 +463,7 @@ class ExportTools(ToolBase):
449
463
  *,
450
464
  profile: str = DEFAULT_PROFILE,
451
465
  app_key: str,
452
- view_id: str = "system:all",
466
+ view_id: str = "",
453
467
  columns: list[JSONObject | int] | None = None,
454
468
  where: list[JSONObject] | None = None,
455
469
  order_by: list[JSONObject] | None = None,
@@ -459,13 +473,19 @@ class ExportTools(ToolBase):
459
473
  wait_timeout_seconds: float | None = None,
460
474
  ) -> dict[str, Any]:
461
475
  normalized_app_key = str(app_key or "").strip()
462
- normalized_view_id = str(view_id or "").strip() or "system:all"
476
+ normalized_view_id = str(view_id or "").strip()
463
477
  if not normalized_app_key:
464
478
  return self._failed_export_result(
465
479
  error_code="EXPORT_START_FAILED",
466
480
  message="app_key is required",
467
481
  extra={"status": "failed", "view_id": normalized_view_id},
468
482
  )
483
+ if not normalized_view_id:
484
+ return self._failed_export_result(
485
+ error_code="EXPORT_VIEW_REQUIRED",
486
+ message="view_id is required; call app_get first and pass accessible_views[].view_id or use the view_id from the frontend URL",
487
+ extra={"status": "failed", "app_key": normalized_app_key, "view_id": normalized_view_id},
488
+ )
469
489
  timeout_seconds = self._normalize_timeout_seconds(wait_timeout_seconds)
470
490
 
471
491
  def runner(session_profile, context):
@@ -509,9 +529,15 @@ class ExportTools(ToolBase):
509
529
  download_to_path=effective_download_path,
510
530
  )
511
531
  if bool(get_result.get("ok")):
532
+ get_result = dict(get_result)
533
+ get_result.setdefault("export_executed", True)
534
+ get_result.setdefault("safe_to_retry_export", False)
535
+ get_result.setdefault("export_handle", export_handle)
512
536
  return get_result
513
537
  return {
514
538
  **get_result,
539
+ "export_executed": True,
540
+ "safe_to_retry_export": False,
515
541
  "export_handle": export_handle,
516
542
  "file_urls": snapshot.get("file_urls") or [],
517
543
  "file_names": snapshot.get("file_names") or [],
@@ -547,8 +573,11 @@ class ExportTools(ToolBase):
547
573
  }
548
574
  if "EXPORT_HISTORY_AMBIGUOUS" in warning_codes:
549
575
  return {
550
- "ok": True,
576
+ "ok": False,
551
577
  "status": "unknown",
578
+ "error_code": "EXPORT_STATUS_UNKNOWN",
579
+ "export_executed": True,
580
+ "safe_to_retry_export": False,
552
581
  "export_handle": export_handle,
553
582
  "app_key": str(local_job.get("app_key") or ""),
554
583
  "view_id": str(local_job.get("view_id") or ""),
@@ -579,8 +608,11 @@ class ExportTools(ToolBase):
579
608
  }
580
609
  )
581
610
  return {
582
- "ok": True,
611
+ "ok": False,
583
612
  "status": timeout_status,
613
+ "error_code": "EXPORT_WAIT_TIMEOUT",
614
+ "export_executed": True,
615
+ "safe_to_retry_export": False,
584
616
  "export_handle": str(start_result.get("export_handle") or ""),
585
617
  "app_key": normalized_app_key,
586
618
  "view_id": str(start_result.get("view_id") or normalized_view_id),
@@ -704,7 +736,7 @@ class ExportTools(ToolBase):
704
736
  order_by: list[JSONObject],
705
737
  select_columns: list[JSONObject],
706
738
  ) -> list[int]:
707
- browse_scope = self._record_tools._build_browse_write_scope(
739
+ browse_scope = self._build_export_read_scope(
708
740
  profile,
709
741
  context,
710
742
  app_key,
@@ -841,7 +873,7 @@ class ExportTools(ToolBase):
841
873
  resolved_view: AccessibleViewRoute,
842
874
  column_selectors: list[int],
843
875
  ) -> tuple[JSONObject, list[JSONObject]]: # type: ignore[no-untyped-def]
844
- browse_scope = self._record_tools._build_browse_write_scope(
876
+ browse_scope = self._build_export_read_scope(
845
877
  profile,
846
878
  context,
847
879
  app_key,
@@ -912,6 +944,71 @@ class ExportTools(ToolBase):
912
944
  )
913
945
  return {"questionExportConfigList": question_export_config_list}, warnings
914
946
 
947
+ def _build_export_read_scope(
948
+ self,
949
+ profile: str,
950
+ context,
951
+ app_key: str,
952
+ resolved_view: AccessibleViewRoute,
953
+ *,
954
+ force_refresh: bool,
955
+ ) -> JSONObject:
956
+ try:
957
+ scope = self._record_tools._build_browse_read_scope(
958
+ profile,
959
+ context,
960
+ app_key,
961
+ resolved_view,
962
+ force_refresh=force_refresh,
963
+ )
964
+ except QingflowApiError as exc:
965
+ if not _is_optional_export_lookup_error(exc):
966
+ raise
967
+ scope = {}
968
+ index = scope.get("index") if isinstance(scope, dict) else None
969
+ if getattr(index, "by_id", None):
970
+ return scope
971
+ if resolved_view.kind == "system" and resolved_view.list_type is not None:
972
+ try:
973
+ list_base_scope = self._build_system_export_list_base_info_scope(context, app_key)
974
+ except QingflowApiError as exc:
975
+ if not _is_optional_export_lookup_error(exc):
976
+ raise
977
+ else:
978
+ list_base_index = list_base_scope.get("index")
979
+ if getattr(list_base_index, "by_id", None):
980
+ return list_base_scope
981
+ try:
982
+ applicant_index = self._record_tools._get_applicant_top_level_field_index(
983
+ profile,
984
+ context,
985
+ app_key,
986
+ force_refresh=force_refresh,
987
+ )
988
+ except QingflowApiError as exc:
989
+ if not _is_optional_export_lookup_error(exc):
990
+ raise
991
+ applicant_index = None
992
+ if applicant_index is not None and applicant_index.by_id:
993
+ visible_question_ids = {field.que_id for field in applicant_index.by_id.values()}
994
+ return {
995
+ "index": applicant_index,
996
+ "writable_field_ids": set(),
997
+ "visible_question_ids": visible_question_ids,
998
+ }
999
+ return scope or {"index": _build_top_level_field_index({}), "writable_field_ids": set(), "visible_question_ids": set()}
1000
+
1001
+ def _build_system_export_list_base_info_scope(self, context, app_key: str) -> JSONObject:
1002
+ payload = self.backend.request("GET", context, f"/app/{app_key}/data/listBaseInfo")
1003
+ schema = _normalize_data_list_base_info_schema(payload)
1004
+ index = _build_top_level_field_index(schema)
1005
+ visible_question_ids = {field.que_id for field in index.by_id.values()}
1006
+ return {
1007
+ "index": index,
1008
+ "writable_field_ids": set(),
1009
+ "visible_question_ids": visible_question_ids,
1010
+ }
1011
+
915
1012
  def _resolve_exportable_fields(
916
1013
  self,
917
1014
  *,
@@ -1047,16 +1144,35 @@ class ExportTools(ToolBase):
1047
1144
  ) -> dict[str, Any]: # type: ignore[no-untyped-def]
1048
1145
  app_key = str(local_job.get("app_key") or "").strip()
1049
1146
  process_payload = self._lookup_process_details(context, app_key=app_key)
1050
- history_page = self.backend.request(
1051
- "GET",
1052
- context,
1053
- "/app/apply/dataExport/record",
1054
- params={"appKey": app_key, "pageNum": 1, "pageSize": 100},
1055
- )
1147
+ history_unavailable_warning: JSONObject | None = None
1148
+ try:
1149
+ history_page = self.backend.request(
1150
+ "GET",
1151
+ context,
1152
+ "/app/apply/dataExport/record",
1153
+ params={"appKey": app_key, "pageNum": 1, "pageSize": 100},
1154
+ )
1155
+ except QingflowApiError as exc:
1156
+ if not _is_optional_export_lookup_error(exc):
1157
+ raise
1158
+ history_page = {"list": []}
1159
+ history_unavailable_warning = {
1160
+ "code": "EXPORT_HISTORY_UNAVAILABLE",
1161
+ "message": "export history is not readable for the current user; using current process details when available.",
1162
+ }
1163
+ if exc.category:
1164
+ history_unavailable_warning["category"] = exc.category
1165
+ if exc.backend_code is not None:
1166
+ history_unavailable_warning["backend_code"] = exc.backend_code
1167
+ if exc.http_status is not None:
1168
+ history_unavailable_warning["http_status"] = exc.http_status
1169
+ if exc.request_id:
1170
+ history_unavailable_warning["request_id"] = exc.request_id
1056
1171
  history_records = _extract_export_records(history_page)
1057
1172
  matched_record, matched_by = _match_export_history_record(history_records, local_job=local_job)
1058
1173
  if process_payload is not None:
1059
1174
  normalized_status = _normalize_export_status(process_payload.get("processStatus") or process_payload.get("status"))
1175
+ process_file_infos = _normalize_export_file_infos(process_payload.get("fileUrls"))
1060
1176
  if normalized_status in {"queued", "running"}:
1061
1177
  return {
1062
1178
  "status": normalized_status,
@@ -1064,22 +1180,47 @@ class ExportTools(ToolBase):
1064
1180
  "num": _coerce_int(process_payload.get("num")),
1065
1181
  "error_code": process_payload.get("errorCode"),
1066
1182
  "audit_record_status": process_payload.get("auditRecordStatus"),
1067
- "file_infos": _normalize_export_file_infos(process_payload.get("fileUrls")),
1068
- "file_urls": _extract_export_file_urls(process_payload.get("fileUrls")),
1069
- "file_names": _extract_export_file_names(process_payload.get("fileUrls")),
1070
- "warnings": [],
1183
+ "file_infos": process_file_infos,
1184
+ "file_urls": [item.get("url") for item in process_file_infos if isinstance(item.get("url"), str)],
1185
+ "file_names": [item.get("name") for item in process_file_infos if isinstance(item.get("name"), str)],
1186
+ "warnings": [history_unavailable_warning] if history_unavailable_warning is not None else [],
1071
1187
  "verification": {
1072
1188
  "current_process_visible": True,
1073
1189
  "history_match_resolved": matched_record is not None,
1190
+ "history_readable": history_unavailable_warning is None,
1074
1191
  },
1075
1192
  "message": None,
1076
1193
  }
1194
+ if normalized_status in {"succeeded", "failed"} and (process_file_infos or normalized_status == "failed"):
1195
+ message = "export failed" if normalized_status == "failed" else None
1196
+ return {
1197
+ "status": normalized_status,
1198
+ "process_status": _coerce_int(process_payload.get("processStatus") or process_payload.get("status")),
1199
+ "num": _coerce_int(process_payload.get("num")),
1200
+ "error_code": process_payload.get("errorCode"),
1201
+ "audit_record_status": process_payload.get("auditRecordStatus"),
1202
+ "file_infos": process_file_infos,
1203
+ "file_urls": [item.get("url") for item in process_file_infos if isinstance(item.get("url"), str)],
1204
+ "file_names": [item.get("name") for item in process_file_infos if isinstance(item.get("name"), str)],
1205
+ "warnings": [history_unavailable_warning] if history_unavailable_warning is not None else [],
1206
+ "verification": {
1207
+ "current_process_visible": True,
1208
+ "history_match_resolved": matched_record is not None,
1209
+ "history_readable": history_unavailable_warning is None,
1210
+ "matched_by": "current_process",
1211
+ },
1212
+ "message": message,
1213
+ }
1077
1214
  if matched_record is None:
1078
1215
  warning_code = "EXPORT_HISTORY_PENDING"
1079
1216
  warning_message = "export has not appeared in export history yet"
1080
1217
  if matched_by == "ambiguous":
1081
1218
  warning_code = "EXPORT_HISTORY_AMBIGUOUS"
1082
1219
  warning_message = "export result could not be matched uniquely in export history"
1220
+ warnings = [{"code": warning_code, "message": warning_message}]
1221
+ if history_unavailable_warning is not None:
1222
+ warnings = [history_unavailable_warning]
1223
+ warning_message = str(history_unavailable_warning["message"])
1083
1224
  return {
1084
1225
  "status": "unknown",
1085
1226
  "process_status": None,
@@ -1089,10 +1230,11 @@ class ExportTools(ToolBase):
1089
1230
  "file_infos": [],
1090
1231
  "file_urls": [],
1091
1232
  "file_names": [],
1092
- "warnings": [{"code": warning_code, "message": warning_message}],
1233
+ "warnings": warnings,
1093
1234
  "verification": {
1094
1235
  "current_process_visible": process_payload is not None,
1095
1236
  "history_match_resolved": False,
1237
+ "history_readable": history_unavailable_warning is None,
1096
1238
  },
1097
1239
  "message": warning_message,
1098
1240
  }
@@ -1129,7 +1271,7 @@ class ExportTools(ToolBase):
1129
1271
  params={"taskType": 2},
1130
1272
  )
1131
1273
  except QingflowApiError as exc:
1132
- if exc.backend_code in {40002, 40027}:
1274
+ if _is_optional_export_lookup_error(exc):
1133
1275
  return None
1134
1276
  raise
1135
1277
  if isinstance(payload, dict):
@@ -1183,9 +1325,9 @@ class ExportTools(ToolBase):
1183
1325
  default_directory: str | None,
1184
1326
  app_key: str,
1185
1327
  view_id: str,
1186
- ) -> list[JSONObject]:
1328
+ ) -> tuple[list[JSONObject], list[JSONObject]]:
1187
1329
  if download_to_path is None and default_directory is None:
1188
- return []
1330
+ return [], []
1189
1331
  effective_hint = download_to_path or default_directory
1190
1332
  assert effective_hint is not None
1191
1333
  targets = _resolve_download_targets(
@@ -1195,13 +1337,45 @@ class ExportTools(ToolBase):
1195
1337
  view_id=view_id,
1196
1338
  )
1197
1339
  downloaded_files: list[JSONObject] = []
1340
+ warnings: list[JSONObject] = []
1198
1341
  for file_info, target in zip(file_infos, targets, strict=False):
1199
1342
  url = str(file_info.get("url") or "").strip()
1200
1343
  if not url:
1201
1344
  continue
1202
- content = self.backend.download_binary(url)
1203
- target.parent.mkdir(parents=True, exist_ok=True)
1204
- target.write_bytes(content)
1345
+ try:
1346
+ content = self.backend.download_binary(url)
1347
+ target.parent.mkdir(parents=True, exist_ok=True)
1348
+ target.write_bytes(content)
1349
+ except QingflowApiError as exc:
1350
+ warning: JSONObject = {
1351
+ "code": "EXPORT_FILE_DOWNLOAD_UNAVAILABLE",
1352
+ "message": "export file link is available, but local download failed; use file_urls or retry download later.",
1353
+ "file_name": str(file_info.get("name") or target.name),
1354
+ "url": url,
1355
+ "category": exc.category,
1356
+ "backend_code": exc.backend_code,
1357
+ "request_id": exc.request_id,
1358
+ "http_status": exc.http_status,
1359
+ }
1360
+ if is_auth_like_error(exc):
1361
+ warning["auth_like"] = True
1362
+ warning["error_code"] = "AUTH_REQUIRED"
1363
+ if exc.details:
1364
+ warning["details"] = exc.details
1365
+ warnings.append(warning)
1366
+ continue
1367
+ except OSError as exc:
1368
+ warnings.append(
1369
+ {
1370
+ "code": "EXPORT_FILE_WRITE_UNAVAILABLE",
1371
+ "message": "export file link is available, but writing the local file failed; use file_urls or retry with another download_to_path.",
1372
+ "file_name": str(file_info.get("name") or target.name),
1373
+ "url": url,
1374
+ "path": str(target),
1375
+ "error": str(exc),
1376
+ }
1377
+ )
1378
+ continue
1205
1379
  downloaded_files.append(
1206
1380
  {
1207
1381
  "file_name": str(file_info.get("name") or target.name),
@@ -1209,7 +1383,7 @@ class ExportTools(ToolBase):
1209
1383
  "url": url,
1210
1384
  }
1211
1385
  )
1212
- return downloaded_files
1386
+ return downloaded_files, warnings
1213
1387
 
1214
1388
  def _normalize_timeout_seconds(self, wait_timeout_seconds: float | None) -> float:
1215
1389
  if wait_timeout_seconds is None:
@@ -1259,10 +1433,16 @@ class ExportTools(ToolBase):
1259
1433
  payload = json.loads(str(error))
1260
1434
  except json.JSONDecodeError:
1261
1435
  payload = {"message": str(error)}
1436
+ details = payload.get("details") if isinstance(payload.get("details"), dict) else {}
1262
1437
  response = self._failed_export_result(
1263
- error_code=((payload.get("details") or {}) if isinstance(payload.get("details"), dict) else {}).get("error_code") or error_code,
1438
+ error_code=details.get("error_code") or _runtime_error_code(payload, default=error_code),
1264
1439
  message=str(payload.get("message") or str(error)),
1265
1440
  )
1441
+ for key in ("category", "backend_code", "request_id", "http_status"):
1442
+ if key in payload:
1443
+ response[key] = payload.get(key)
1444
+ if details:
1445
+ response["details"] = details
1266
1446
  if extra:
1267
1447
  response.update(extra)
1268
1448
  return response
@@ -1279,6 +1459,23 @@ def _extract_export_records(payload: Any) -> list[JSONObject]:
1279
1459
  return []
1280
1460
 
1281
1461
 
1462
+ def _is_optional_export_lookup_error(error: QingflowApiError) -> bool:
1463
+ if is_auth_like_error(error):
1464
+ return False
1465
+ backend_code = backend_code_int(error)
1466
+ return backend_code in {40002, 40027, 404} or error.http_status == 404
1467
+
1468
+
1469
+ def _runtime_error_code(payload: JSONObject, *, default: str) -> str:
1470
+ category = str(payload.get("category") or "").strip().lower()
1471
+ http_status = _coerce_int(payload.get("http_status"))
1472
+ if category == "auth" or http_status == 401 or message_looks_like_invalid_token(payload.get("message")):
1473
+ return "AUTH_REQUIRED"
1474
+ if category == "workspace":
1475
+ return "WORKSPACE_NOT_SELECTED"
1476
+ return default
1477
+
1478
+
1282
1479
  def _match_export_history_record(
1283
1480
  records: list[JSONObject],
1284
1481
  *,
@@ -12,12 +12,12 @@ from urllib.parse import quote
12
12
  from mcp.server.fastmcp import FastMCP
13
13
 
14
14
  from ..config import DEFAULT_PROFILE
15
- from ..errors import QingflowApiError, raise_tool_error
15
+ from ..errors import QingflowApiError, backend_code_int, is_auth_like_error, raise_tool_error
16
16
  from ..json_types import JSONObject
17
17
  from .base import ToolBase, tool_cn_name
18
18
 
19
19
 
20
- ATTACHMENT_UPLOAD_INFO_FALLBACK_CODES = {"40118", 40118}
20
+ ATTACHMENT_UPLOAD_INFO_FALLBACK_CODES = {40118}
21
21
  LEGACY_OSS_FORM_REQUIRED_KEYS = ("key", "policy", "signature", "ossAccessKeyId")
22
22
 
23
23
 
@@ -190,6 +190,8 @@ class FileTools(ToolBase):
190
190
  "result": result,
191
191
  "upload_result": upload_result,
192
192
  "download_url": download_url,
193
+ "write_executed": True,
194
+ "safe_to_retry": False,
193
195
  "attachment_value": {
194
196
  "value": download_url,
195
197
  "otherInfo": file_name,
@@ -357,10 +359,12 @@ class FileTools(ToolBase):
357
359
  error: QingflowApiError,
358
360
  ) -> bool:
359
361
  """执行内部辅助逻辑。"""
362
+ if is_auth_like_error(error):
363
+ return False
360
364
  return (
361
365
  requested_kind == "attachment"
362
366
  and attempted_kind == "attachment"
363
- and error.backend_code in ATTACHMENT_UPLOAD_INFO_FALLBACK_CODES
367
+ and backend_code_int(error) in ATTACHMENT_UPLOAD_INFO_FALLBACK_CODES
364
368
  )
365
369
 
366
370
  def _is_legacy_oss_form_upload(self, payload: JSONObject) -> bool: