@qingflow-tech/qingflow-app-user-mcp 1.0.11 → 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 (86) hide show
  1. package/README.md +9 -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 +255 -0
  10. package/skills/qingflow-app-builder/agents/openai.yaml +4 -0
  11. package/skills/qingflow-app-builder/references/create-app.md +149 -0
  12. package/skills/qingflow-app-builder/references/environments.md +63 -0
  13. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
  14. package/skills/qingflow-app-builder/references/gotchas.md +107 -0
  15. package/skills/qingflow-app-builder/references/match-rules.md +114 -0
  16. package/skills/qingflow-app-builder/references/public-surface-sync.md +75 -0
  17. package/skills/qingflow-app-builder/references/solution-playbooks.md +52 -0
  18. package/skills/qingflow-app-builder/references/tool-selection.md +99 -0
  19. package/skills/qingflow-app-builder/references/update-flow.md +158 -0
  20. package/skills/qingflow-app-builder/references/update-layout.md +68 -0
  21. package/skills/qingflow-app-builder/references/update-schema.md +72 -0
  22. package/skills/qingflow-app-builder/references/update-views.md +284 -0
  23. package/skills/qingflow-app-builder-code-integrations/SKILL.md +137 -0
  24. package/skills/qingflow-app-builder-code-integrations/agents/openai.yaml +4 -0
  25. package/skills/qingflow-app-builder-code-integrations/references/code-block.md +66 -0
  26. package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +77 -0
  27. package/skills/qingflow-app-user/SKILL.md +12 -11
  28. package/skills/qingflow-app-user/references/data-gotchas.md +2 -2
  29. package/skills/qingflow-app-user/references/public-surface-sync.md +3 -3
  30. package/skills/qingflow-app-user/references/record-patterns.md +5 -5
  31. package/skills/qingflow-app-user/references/workflow-usage.md +4 -5
  32. package/skills/qingflow-mcp-setup/SKILL.md +113 -0
  33. package/skills/qingflow-mcp-setup/agents/openai.yaml +4 -0
  34. package/skills/qingflow-mcp-setup/references/claude-desktop.md +34 -0
  35. package/skills/qingflow-mcp-setup/references/environments.md +62 -0
  36. package/skills/qingflow-mcp-setup/references/generic-stdio.md +32 -0
  37. package/skills/qingflow-mcp-setup/scripts/check_local_server.sh +38 -0
  38. package/skills/qingflow-record-analysis/SKILL.md +6 -7
  39. package/skills/qingflow-record-analysis/manifest.yaml +10 -0
  40. package/skills/qingflow-record-delete/SKILL.md +5 -3
  41. package/skills/qingflow-record-import/SKILL.md +6 -2
  42. package/skills/qingflow-record-insert/SKILL.md +48 -4
  43. package/skills/qingflow-record-insert/manifest.yaml +6 -0
  44. package/skills/qingflow-record-update/SKILL.md +36 -24
  45. package/skills/qingflow-task-ops/SKILL.md +25 -25
  46. package/skills/qingflow-task-ops/references/environments.md +0 -1
  47. package/skills/qingflow-task-ops/references/workflow-usage.md +4 -6
  48. package/src/qingflow_mcp/__main__.py +6 -2
  49. package/src/qingflow_mcp/builder_facade/service.py +1488 -288
  50. package/src/qingflow_mcp/cli/commands/builder.py +2 -2
  51. package/src/qingflow_mcp/cli/commands/exports.py +2 -2
  52. package/src/qingflow_mcp/cli/commands/imports.py +1 -1
  53. package/src/qingflow_mcp/cli/commands/record.py +39 -11
  54. package/src/qingflow_mcp/cli/context.py +0 -3
  55. package/src/qingflow_mcp/cli/formatters.py +206 -7
  56. package/src/qingflow_mcp/cli/main.py +47 -3
  57. package/src/qingflow_mcp/errors.py +43 -2
  58. package/src/qingflow_mcp/public_surface.py +21 -15
  59. package/src/qingflow_mcp/response_trim.py +68 -13
  60. package/src/qingflow_mcp/server.py +11 -9
  61. package/src/qingflow_mcp/server_app_builder.py +3 -2
  62. package/src/qingflow_mcp/server_app_user.py +15 -13
  63. package/src/qingflow_mcp/solution/executor.py +112 -15
  64. package/src/qingflow_mcp/tools/ai_builder_tools.py +36 -11
  65. package/src/qingflow_mcp/tools/app_tools.py +184 -43
  66. package/src/qingflow_mcp/tools/approval_tools.py +196 -34
  67. package/src/qingflow_mcp/tools/auth_tools.py +92 -16
  68. package/src/qingflow_mcp/tools/code_block_tools.py +296 -39
  69. package/src/qingflow_mcp/tools/custom_button_tools.py +64 -10
  70. package/src/qingflow_mcp/tools/directory_tools.py +236 -72
  71. package/src/qingflow_mcp/tools/export_tools.py +230 -33
  72. package/src/qingflow_mcp/tools/file_tools.py +7 -3
  73. package/src/qingflow_mcp/tools/import_tools.py +293 -40
  74. package/src/qingflow_mcp/tools/navigation_tools.py +91 -12
  75. package/src/qingflow_mcp/tools/package_tools.py +118 -6
  76. package/src/qingflow_mcp/tools/portal_tools.py +39 -3
  77. package/src/qingflow_mcp/tools/qingbi_report_tools.py +116 -7
  78. package/src/qingflow_mcp/tools/record_tools.py +1042 -338
  79. package/src/qingflow_mcp/tools/resource_read_tools.py +188 -39
  80. package/src/qingflow_mcp/tools/role_tools.py +80 -9
  81. package/src/qingflow_mcp/tools/solution_tools.py +57 -15
  82. package/src/qingflow_mcp/tools/task_context_tools.py +569 -119
  83. package/src/qingflow_mcp/tools/task_tools.py +113 -29
  84. package/src/qingflow_mcp/tools/view_tools.py +106 -3
  85. package/src/qingflow_mcp/tools/workflow_tools.py +17 -1
  86. package/src/qingflow_mcp/tools/workspace_tools.py +71 -3
@@ -17,7 +17,7 @@ from mcp.server.fastmcp import FastMCP
17
17
  from openpyxl import Workbook, load_workbook
18
18
 
19
19
  from ..config import DEFAULT_PROFILE
20
- from ..errors import QingflowApiError
20
+ from ..errors import QingflowApiError, backend_code_int, is_auth_like_error, message_looks_like_invalid_token
21
21
  from ..import_store import ImportJobStore, ImportVerificationStore
22
22
  from ..json_types import JSONObject
23
23
  from .app_tools import _derive_import_capability
@@ -193,6 +193,22 @@ class ImportTools(ToolBase):
193
193
 
194
194
  def runner(session_profile, context):
195
195
  import_capability, import_warnings = self._fetch_import_capability(context, app_key)
196
+ if import_capability.get("can_import") is False and import_capability.get("auth_source") != "unknown":
197
+ return {
198
+ "ok": False,
199
+ "status": "failed",
200
+ "app_key": app_key,
201
+ "ws_id": session_profile.selected_ws_id,
202
+ "request_route": self.backend.describe_route(context),
203
+ "error_code": "IMPORT_AUTH_PRECHECK_FAILED",
204
+ "message": "the current user does not have import permission for this app",
205
+ "warnings": import_warnings,
206
+ "import_capability": import_capability,
207
+ "verification": {
208
+ "import_auth_prechecked": True,
209
+ "import_auth_precheck_passed": False,
210
+ },
211
+ }
196
212
  _index, expected_columns, schema_fingerprint = self._resolve_import_schema_bundle(
197
213
  profile,
198
214
  context,
@@ -251,6 +267,21 @@ class ImportTools(ToolBase):
251
267
 
252
268
  def runner(session_profile, context):
253
269
  import_capability, import_warnings = self._fetch_import_capability(context, app_key)
270
+ if import_capability.get("can_import") is False and import_capability.get("auth_source") != "unknown":
271
+ return self._failed_template_result(
272
+ app_key=app_key,
273
+ error_code="IMPORT_AUTH_PRECHECK_FAILED",
274
+ message="the current user does not have import permission for this app",
275
+ request_route=self.backend.describe_route(context),
276
+ extra={
277
+ "warnings": import_warnings,
278
+ "import_capability": import_capability,
279
+ "verification": {
280
+ "import_auth_prechecked": True,
281
+ "import_auth_precheck_passed": False,
282
+ },
283
+ },
284
+ )
254
285
  field_index, expected_columns, schema_fingerprint = self._resolve_import_schema_bundle(
255
286
  profile,
256
287
  context,
@@ -260,12 +291,30 @@ class ImportTools(ToolBase):
260
291
  try:
261
292
  payload = self.backend.request("GET", context, f"/app/{app_key}/apply/excelTemplate")
262
293
  except QingflowApiError as exc:
263
- if import_capability.get("auth_source") == "apply_auth":
294
+ can_generate_local_template = bool(expected_columns) and (
295
+ import_capability.get("auth_source") == "apply_auth"
296
+ or (
297
+ import_capability.get("auth_source") == "unknown"
298
+ and not is_auth_like_error(exc)
299
+ and backend_code_int(exc) in {40002, 40027}
300
+ )
301
+ )
302
+ if can_generate_local_template:
264
303
  downloaded_to_path = self._write_local_template(
265
304
  expected_columns=expected_columns,
266
305
  destination_hint=download_to_path,
267
306
  app_key=app_key,
268
307
  )
308
+ template_warning = {
309
+ "code": "IMPORT_TEMPLATE_LOCAL_FALLBACK",
310
+ "message": "Official template download requires data management permission; MCP generated a local applicant-import template instead.",
311
+ }
312
+ if import_capability.get("auth_source") == "unknown":
313
+ template_warning = {
314
+ "code": "IMPORT_TEMPLATE_LOCAL_FALLBACK_AUTH_UNKNOWN",
315
+ "message": "Official template download was permission-restricted and import permission could not be prechecked; MCP generated a local applicant-import template from readable applicant fields.",
316
+ }
317
+ _copy_api_error_fields(template_warning, exc)
269
318
  return {
270
319
  "ok": True,
271
320
  "status": "partial_success",
@@ -276,18 +325,13 @@ class ImportTools(ToolBase):
276
325
  "downloaded_to_path": downloaded_to_path,
277
326
  "expected_columns": expected_columns,
278
327
  "schema_fingerprint": schema_fingerprint,
279
- "warnings": import_warnings
280
- + [
281
- {
282
- "code": "IMPORT_TEMPLATE_LOCAL_FALLBACK",
283
- "message": "Official template download requires data management permission; MCP generated a local applicant-import template instead.",
284
- }
285
- ],
328
+ "warnings": import_warnings + [template_warning],
286
329
  "verification": {
287
330
  "schema_fingerprint": schema_fingerprint,
288
331
  "template_url_resolved": False,
289
332
  "template_downloaded": True,
290
333
  "template_source": "local_generated",
334
+ "import_auth_prechecked": import_capability.get("auth_source") != "unknown",
291
335
  },
292
336
  }
293
337
  return self._failed_template_result(
@@ -448,6 +492,7 @@ class ImportTools(ToolBase):
448
492
  issues = deepcopy(effective_local_check["issues"])
449
493
  can_import = bool(effective_local_check["can_import"])
450
494
  backend_verification = None
495
+ backend_failure_error_code = None
451
496
  if can_import:
452
497
  try:
453
498
  payload = self.backend.request_multipart(
@@ -478,19 +523,25 @@ class ImportTools(ToolBase):
478
523
  )
479
524
  except QingflowApiError as exc:
480
525
  can_import = False
526
+ backend_error_code = _import_permission_error_code(
527
+ exc,
528
+ permission_code="IMPORT_VERIFICATION_UNAUTHORIZED",
529
+ default="BACKEND_IMPORT_VERIFICATION_FAILED",
530
+ )
531
+ backend_failure_error_code = backend_error_code
481
532
  issues.append(
482
533
  _issue(
483
- "BACKEND_IMPORT_VERIFICATION_FAILED",
534
+ backend_error_code,
484
535
  exc.message or "Backend import verification failed.",
485
536
  severity="error",
486
537
  )
487
538
  )
488
- warnings.append(
489
- {
490
- "code": "IMPORT_VERIFICATION_FAILED",
491
- "message": "Backend verification failed; the file cannot be imported until verification succeeds.",
492
- }
493
- )
539
+ warning = {
540
+ "code": backend_error_code,
541
+ "message": "Backend verification failed; the file cannot be imported until verification succeeds.",
542
+ }
543
+ _copy_api_error_fields(warning, exc)
544
+ warnings.append(warning)
494
545
  verification_id = str(uuid4())
495
546
  verification_payload = {
496
547
  "id": verification_id,
@@ -517,9 +568,11 @@ class ImportTools(ToolBase):
517
568
  }
518
569
  self._verification_store.put(verification_id, verification_payload)
519
570
  return {
520
- "ok": True,
571
+ "ok": can_import,
521
572
  "status": "success" if can_import else "failed",
522
- "error_code": None if can_import else (effective_local_check.get("error_code") or local_check.get("error_code") or "IMPORT_VERIFICATION_FAILED"),
573
+ "error_code": None
574
+ if can_import
575
+ else (effective_local_check.get("error_code") or local_check.get("error_code") or backend_failure_error_code or "IMPORT_VERIFICATION_FAILED"),
523
576
  "can_import": can_import,
524
577
  "verification_id": verification_id,
525
578
  "file_path": str(path.resolve()),
@@ -553,7 +606,11 @@ class ImportTools(ToolBase):
553
606
  try:
554
607
  return self._run(profile, runner)
555
608
  except RuntimeError as exc:
556
- return self._runtime_error_as_result(exc, error_code="IMPORT_VERIFICATION_FAILED", extra={"can_import": False})
609
+ return self._runtime_error_as_result(
610
+ exc,
611
+ error_code="IMPORT_VERIFICATION_FAILED",
612
+ extra={"can_import": _runtime_import_can_import_value(exc)},
613
+ )
557
614
 
558
615
  @tool_cn_name("导入修复")
559
616
  def record_import_repair_local(
@@ -729,8 +786,14 @@ class ImportTools(ToolBase):
729
786
  excel_name=str(stored.get("file_name") or current_path.name),
730
787
  )
731
788
  except QingflowApiError as exc:
732
- error_code = "IMPORT_SOCKET_ACK_TIMEOUT" if exc.details and exc.details.get("error_code") == "IMPORT_SOCKET_ACK_TIMEOUT" else "IMPORT_VERIFICATION_FAILED"
733
- return self._failed_start_result(error_code=error_code, message=exc.message, extra={"accepted": False, "file_url": file_url})
789
+ error_code = (
790
+ "IMPORT_SOCKET_ACK_TIMEOUT"
791
+ if exc.details and exc.details.get("error_code") == "IMPORT_SOCKET_ACK_TIMEOUT"
792
+ else _import_permission_error_code(exc, permission_code="IMPORT_START_UNAUTHORIZED", default="IMPORT_START_FAILED")
793
+ )
794
+ details: JSONObject = {"accepted": False, "file_url": file_url}
795
+ _copy_api_error_fields(details, exc)
796
+ return self._failed_start_result(error_code=error_code, message=exc.message, extra=details)
734
797
  import_id = str(socket_result.get("import_id") or "")
735
798
  process_id_str = _normalize_optional_text(socket_result.get("process_id_str"))
736
799
  started_at = _utc_now().isoformat()
@@ -753,9 +816,11 @@ class ImportTools(ToolBase):
753
816
  "ok": True,
754
817
  "status": "accepted",
755
818
  "accepted": True,
819
+ "write_executed": True,
820
+ "safe_to_retry": False,
756
821
  "import_id": import_id,
757
822
  "process_id_str": process_id_str,
758
- "source_file_name": str(stored.get("file_name") or current_path.name),
823
+ "source_file_name": str(stored.get("file_name") or current_path.name),
759
824
  "file_url": file_url,
760
825
  "warnings": warnings,
761
826
  "verification": {
@@ -838,12 +903,66 @@ class ImportTools(ToolBase):
838
903
  if local_job is None and not normalized_import_id and not normalized_process_id:
839
904
  recent = [item for item in self._job_store.list() if str(item.get("app_key")) == resolved_app_key]
840
905
  local_job = recent[0] if recent else None
841
- page = self.backend.request(
842
- "GET",
843
- context,
844
- "/app/apply/dataImport/record",
845
- params={"appKey": resolved_app_key, "pageNum": 1, "pageSize": 100},
846
- )
906
+ try:
907
+ page = self.backend.request(
908
+ "GET",
909
+ context,
910
+ "/app/apply/dataImport/record",
911
+ params={"appKey": resolved_app_key, "pageNum": 1, "pageSize": 100},
912
+ )
913
+ except QingflowApiError as exc:
914
+ error_code = _import_permission_error_code(exc, permission_code="IMPORT_STATUS_UNAUTHORIZED", default="IMPORT_STATUS_UNAVAILABLE")
915
+ details: JSONObject = {
916
+ "app_key": resolved_app_key,
917
+ "import_id": normalized_import_id,
918
+ "process_id_str": effective_process_id,
919
+ "verification": {
920
+ "status_lookup_completed": False,
921
+ "process_id_verified": bool(effective_process_id),
922
+ },
923
+ }
924
+ _copy_api_error_fields(details, exc)
925
+ if _is_import_permission_error(exc):
926
+ return {
927
+ "ok": True,
928
+ "status": "unknown",
929
+ "error_code": error_code,
930
+ "app_key": resolved_app_key,
931
+ "import_id": normalized_import_id or (local_job.get("import_id") if isinstance(local_job, dict) else None),
932
+ "process_id_str": effective_process_id,
933
+ "matched_by": "local_job" if isinstance(local_job, dict) else None,
934
+ "source_file_name": local_job.get("source_file_name") if isinstance(local_job, dict) else None,
935
+ "total_rows": None,
936
+ "success_rows": None,
937
+ "failed_rows": None,
938
+ "progress": None,
939
+ "error_file_urls": [],
940
+ "operate_time": None,
941
+ "operate_user": None,
942
+ "accepted": bool(local_job or effective_process_id),
943
+ "warnings": [
944
+ {
945
+ "code": error_code,
946
+ "message": "import history is not readable; final import status is unknown",
947
+ **{
948
+ key: details.get(key)
949
+ for key in ("category", "backend_code", "http_status", "request_id")
950
+ if details.get(key) is not None
951
+ },
952
+ }
953
+ ],
954
+ "verification": details["verification"],
955
+ "details": {"status_readback_error": details},
956
+ "backend_code": details.get("backend_code"),
957
+ "request_id": details.get("request_id"),
958
+ "http_status": details.get("http_status"),
959
+ "message": "import history is not readable; final import status is unknown",
960
+ }
961
+ return self._failed_status_result(
962
+ error_code=error_code,
963
+ message=exc.message,
964
+ extra=details,
965
+ )
847
966
  records = _extract_import_records(page)
848
967
  matched_record, matched_by = _match_import_record(
849
968
  records,
@@ -916,7 +1035,19 @@ class ImportTools(ToolBase):
916
1035
  try:
917
1036
  return self._run(profile, runner)
918
1037
  except RuntimeError as exc:
919
- return self._runtime_error_as_result(exc, error_code="IMPORT_STATUS_AMBIGUOUS")
1038
+ return self._runtime_error_as_result(
1039
+ exc,
1040
+ error_code="IMPORT_STATUS_AMBIGUOUS",
1041
+ extra={
1042
+ "app_key": normalized_app_key,
1043
+ "import_id": normalized_import_id,
1044
+ "process_id_str": normalized_process_id,
1045
+ "verification": {
1046
+ "status_lookup_completed": False,
1047
+ "process_id_verified": bool(normalized_process_id),
1048
+ },
1049
+ },
1050
+ )
920
1051
 
921
1052
  def _resolve_import_schema_bundle(
922
1053
  self,
@@ -1218,7 +1349,9 @@ class ImportTools(ToolBase):
1218
1349
  "message": f"Member candidate scope for column '{column['title']}' could not be resolved safely during local precheck.",
1219
1350
  }
1220
1351
  raise
1221
- except RuntimeError:
1352
+ except RuntimeError as exc:
1353
+ if not _runtime_candidate_validation_skippable(exc):
1354
+ raise
1222
1355
  return None, {
1223
1356
  "code": "MEMBER_CANDIDATE_VALIDATION_SKIPPED",
1224
1357
  "message": f"Member candidate scope for column '{column['title']}' could not be resolved safely during local precheck.",
@@ -1272,7 +1405,9 @@ class ImportTools(ToolBase):
1272
1405
  "message": f"Department candidate scope for column '{column['title']}' could not be resolved safely during local precheck.",
1273
1406
  }
1274
1407
  raise
1275
- except RuntimeError:
1408
+ except RuntimeError as exc:
1409
+ if not _runtime_candidate_validation_skippable(exc):
1410
+ raise
1276
1411
  return None, {
1277
1412
  "code": "DEPARTMENT_CANDIDATE_VALIDATION_SKIPPED",
1278
1413
  "message": f"Department candidate scope for column '{column['title']}' could not be resolved safely during local precheck.",
@@ -1397,11 +1532,31 @@ class ImportTools(ToolBase):
1397
1532
 
1398
1533
  def _fetch_import_capability(self, context, app_key: str) -> tuple[JSONObject, list[JSONObject]]: # type: ignore[no-untyped-def]
1399
1534
  """执行内部辅助逻辑。"""
1535
+ base_info_error: QingflowApiError | None = None
1400
1536
  try:
1401
1537
  payload = self.backend.request("GET", context, f"/app/{app_key}/baseInfo")
1402
- except QingflowApiError:
1538
+ except QingflowApiError as exc:
1539
+ if not _is_optional_import_capability_error(exc):
1540
+ raise
1541
+ base_info_error = exc
1403
1542
  payload = None
1404
- return _derive_import_capability(payload)
1543
+ capability, warnings = _derive_import_capability(payload)
1544
+ if base_info_error is not None:
1545
+ for warning in warnings:
1546
+ if warning.get("code") != "IMPORT_CAPABILITY_UNAVAILABLE":
1547
+ continue
1548
+ warning["message"] = (
1549
+ "import capability precheck could not read app baseInfo; continuing with applicant schema "
1550
+ "when available, but do not treat import permission as verified."
1551
+ )
1552
+ if base_info_error.backend_code is not None:
1553
+ warning["backend_code"] = base_info_error.backend_code
1554
+ if base_info_error.http_status is not None:
1555
+ warning["http_status"] = base_info_error.http_status
1556
+ if base_info_error.request_id is not None:
1557
+ warning["request_id"] = base_info_error.request_id
1558
+ break
1559
+ return capability, warnings
1405
1560
 
1406
1561
  def _write_local_template(
1407
1562
  self,
@@ -1430,9 +1585,10 @@ class ImportTools(ToolBase):
1430
1585
  error_code: str,
1431
1586
  message: str,
1432
1587
  request_route: JSONObject | None = None,
1588
+ extra: dict[str, Any] | None = None,
1433
1589
  ) -> dict[str, Any]:
1434
1590
  """执行内部辅助逻辑。"""
1435
- return {
1591
+ payload = {
1436
1592
  "ok": False,
1437
1593
  "status": "failed",
1438
1594
  "error_code": error_code,
@@ -1446,6 +1602,9 @@ class ImportTools(ToolBase):
1446
1602
  "verification": {"template_url_resolved": False},
1447
1603
  "message": message,
1448
1604
  }
1605
+ if extra:
1606
+ payload.update(extra)
1607
+ return payload
1449
1608
 
1450
1609
  def _failed_verify_result(
1451
1610
  self,
@@ -1458,7 +1617,7 @@ class ImportTools(ToolBase):
1458
1617
  ) -> dict[str, Any]:
1459
1618
  """执行内部辅助逻辑。"""
1460
1619
  payload = {
1461
- "ok": True,
1620
+ "ok": False,
1462
1621
  "status": "failed",
1463
1622
  "error_code": error_code,
1464
1623
  "app_key": app_key,
@@ -1573,18 +1732,21 @@ class ImportTools(ToolBase):
1573
1732
  extra: dict[str, Any] | None = None,
1574
1733
  ) -> dict[str, Any]:
1575
1734
  """执行内部辅助逻辑。"""
1576
- try:
1577
- payload = json.loads(str(error))
1578
- except json.JSONDecodeError:
1579
- payload = {"message": str(error)}
1735
+ payload = _runtime_error_payload(error)
1736
+ details = payload.get("details") if isinstance(payload.get("details"), dict) else {}
1580
1737
  response = {
1581
1738
  "ok": False,
1582
1739
  "status": "failed",
1583
- "error_code": ((payload.get("details") or {}) if isinstance(payload.get("details"), dict) else {}).get("error_code") or error_code,
1740
+ "error_code": details.get("error_code") or _runtime_error_code(payload, default=error_code),
1584
1741
  "warnings": [],
1585
1742
  "verification": {},
1586
1743
  "message": payload.get("message") or str(error),
1587
1744
  }
1745
+ for key in ("category", "backend_code", "request_id", "http_status"):
1746
+ if key in payload:
1747
+ response[key] = payload.get(key)
1748
+ if details:
1749
+ response["details"] = details
1588
1750
  if extra:
1589
1751
  response.update(extra)
1590
1752
  return response
@@ -2257,6 +2419,97 @@ def _normalize_import_status(value: Any) -> str:
2257
2419
  return "unknown"
2258
2420
 
2259
2421
 
2422
+ def _runtime_error_payload(error: RuntimeError) -> JSONObject:
2423
+ try:
2424
+ payload = json.loads(str(error))
2425
+ except json.JSONDecodeError:
2426
+ return {"message": str(error)}
2427
+ return payload if isinstance(payload, dict) else {"message": str(error)}
2428
+
2429
+
2430
+ def _runtime_import_can_import_value(error: RuntimeError) -> bool | None:
2431
+ payload = _runtime_error_payload(error)
2432
+ category = str(payload.get("category") or "").strip().lower()
2433
+ if category == "auth" or _coerce_int(payload.get("http_status")) == 401 or message_looks_like_invalid_token(payload.get("message")):
2434
+ return None
2435
+ backend_code = backend_code_int(
2436
+ QingflowApiError(
2437
+ category=category,
2438
+ message=str(payload.get("message") or ""),
2439
+ backend_code=payload.get("backend_code"),
2440
+ http_status=_coerce_int(payload.get("http_status")),
2441
+ )
2442
+ )
2443
+ return False if backend_code in {40002, 40027} else None
2444
+
2445
+
2446
+ def _runtime_candidate_validation_skippable(error: RuntimeError) -> bool:
2447
+ payload = _runtime_error_payload(error)
2448
+ category = str(payload.get("category") or "").strip().lower()
2449
+ if category == "not_supported":
2450
+ return True
2451
+ if category in {"auth", "workspace"}:
2452
+ return False
2453
+ if message_looks_like_invalid_token(payload.get("message")):
2454
+ return False
2455
+ if _coerce_int(payload.get("http_status")) == 401:
2456
+ return False
2457
+ backend_code = backend_code_int(
2458
+ QingflowApiError(
2459
+ category=category,
2460
+ message=str(payload.get("message") or ""),
2461
+ backend_code=payload.get("backend_code"),
2462
+ http_status=_coerce_int(payload.get("http_status")),
2463
+ )
2464
+ )
2465
+ return backend_code in {40002, 40027, 404} or _coerce_int(payload.get("http_status")) == 404
2466
+
2467
+
2468
+ def _runtime_error_code(payload: JSONObject, *, default: str) -> str:
2469
+ category = str(payload.get("category") or "").strip().lower()
2470
+ http_status = _coerce_int(payload.get("http_status"))
2471
+ if category == "auth" or http_status == 401 or message_looks_like_invalid_token(payload.get("message")):
2472
+ return "AUTH_REQUIRED"
2473
+ if category == "workspace":
2474
+ return "WORKSPACE_NOT_SELECTED"
2475
+ return default
2476
+
2477
+
2478
+ def _is_import_permission_error(error: QingflowApiError) -> bool:
2479
+ if is_auth_like_error(error):
2480
+ return False
2481
+ return backend_code_int(error) in {40002, 40027}
2482
+
2483
+
2484
+ def _is_import_capability_auth_error(error: QingflowApiError) -> bool:
2485
+ return is_auth_like_error(error)
2486
+
2487
+
2488
+ def _is_optional_import_capability_error(error: QingflowApiError) -> bool:
2489
+ if _is_import_capability_auth_error(error):
2490
+ return False
2491
+ return _is_import_permission_error(error) or backend_code_int(error) == 404 or error.http_status == 404
2492
+
2493
+
2494
+ def _import_permission_error_code(error: QingflowApiError, *, permission_code: str, default: str) -> str:
2495
+ if is_auth_like_error(error):
2496
+ return "AUTH_REQUIRED"
2497
+ if _is_import_permission_error(error):
2498
+ return permission_code
2499
+ return default
2500
+
2501
+
2502
+ def _copy_api_error_fields(payload: dict[str, Any], error: QingflowApiError) -> None:
2503
+ if error.category:
2504
+ payload["category"] = error.category
2505
+ if error.backend_code is not None:
2506
+ payload["backend_code"] = error.backend_code
2507
+ if error.http_status is not None:
2508
+ payload["http_status"] = error.http_status
2509
+ if error.request_id:
2510
+ payload["request_id"] = error.request_id
2511
+
2512
+
2260
2513
  def _normalize_error_file_urls(value: Any) -> list[str]:
2261
2514
  if isinstance(value, list):
2262
2515
  return [str(item).strip() for item in value if str(item).strip()]
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from mcp.server.fastmcp import FastMCP
4
4
 
5
5
  from ..config import DEFAULT_PROFILE
6
- from ..errors import QingflowApiError, raise_tool_error
6
+ from ..errors import QingflowApiError, backend_code_int, is_auth_like_error, raise_tool_error
7
7
  from ..json_types import JSONObject
8
8
  from .base import ToolBase, tool_cn_name
9
9
 
@@ -85,8 +85,26 @@ class NavigationTools(ToolBase):
85
85
  params: JSONObject = {"pageNum": page_num, "pageSize": page_size}
86
86
  if query_key:
87
87
  params["queryCondition"] = query_key
88
- result = self.backend.request("GET", context, "/navigation/page", params=params)
89
- return {"profile": profile, "ws_id": session_profile.selected_ws_id, "page": result}
88
+ fallback_error: QingflowApiError | None = None
89
+ try:
90
+ result = self.backend.request("GET", context, "/navigation/page", params=params)
91
+ response = {"profile": profile, "ws_id": session_profile.selected_ws_id, "status": "draft", "page": result}
92
+ except QingflowApiError as exc:
93
+ if not _is_optional_draft_navigation_read_error(exc):
94
+ raise
95
+ fallback_error = exc
96
+ result = self.backend.request("GET", context, "/navigation", params={"pageNum": page_num, "pageSize": page_size})
97
+ response = {
98
+ "profile": profile,
99
+ "ws_id": session_profile.selected_ws_id,
100
+ "status": "published",
101
+ "requested_status": "draft",
102
+ "page": result,
103
+ }
104
+ if fallback_error is not None:
105
+ response["warnings"] = [_navigation_fallback_warning("NAVIGATION_DRAFT_PAGE_UNAVAILABLE", fallback_error)]
106
+ response["verification"] = {"draft_readable": False, "published_fallback_used": True}
107
+ return response
90
108
 
91
109
  return self._run(profile, runner)
92
110
 
@@ -97,8 +115,26 @@ class NavigationTools(ToolBase):
97
115
  params: JSONObject = {}
98
116
  if query_key:
99
117
  params["queryCondition"] = query_key
100
- result = self.backend.request("GET", context, "/navigation/all", params=params)
101
- return {"profile": profile, "ws_id": session_profile.selected_ws_id, "items": result}
118
+ fallback_error: QingflowApiError | None = None
119
+ try:
120
+ result = self.backend.request("GET", context, "/navigation/all", params=params)
121
+ response = {"profile": profile, "ws_id": session_profile.selected_ws_id, "status": "draft", "items": result}
122
+ except QingflowApiError as exc:
123
+ if not _is_optional_draft_navigation_read_error(exc):
124
+ raise
125
+ fallback_error = exc
126
+ result = self.backend.request("GET", context, "/navigation", params={"pageNum": 1, "pageSize": 1000})
127
+ response = {
128
+ "profile": profile,
129
+ "ws_id": session_profile.selected_ws_id,
130
+ "status": "published",
131
+ "requested_status": "draft",
132
+ "items": result,
133
+ }
134
+ if fallback_error is not None:
135
+ response["warnings"] = [_navigation_fallback_warning("NAVIGATION_DRAFT_ALL_UNAVAILABLE", fallback_error)]
136
+ response["verification"] = {"draft_readable": False, "published_fallback_used": True}
137
+ return response
102
138
 
103
139
  return self._run(profile, runner)
104
140
 
@@ -108,13 +144,39 @@ class NavigationTools(ToolBase):
108
144
  self._require_navigation_item_id(navigation_item_id)
109
145
 
110
146
  def runner(session_profile, context):
111
- result = self.backend.request(
112
- "GET",
113
- context,
114
- f"/navigation/detail/{navigation_item_id}",
115
- params={"status": status},
116
- )
117
- return {"profile": profile, "ws_id": session_profile.selected_ws_id, "navigation_item_id": navigation_item_id, "result": result}
147
+ requested_status = str(status or "draft")
148
+ fallback_error: QingflowApiError | None = None
149
+ try:
150
+ result = self.backend.request(
151
+ "GET",
152
+ context,
153
+ f"/navigation/detail/{navigation_item_id}",
154
+ params={"status": requested_status},
155
+ )
156
+ effective_status = requested_status
157
+ except QingflowApiError as exc:
158
+ if requested_status != "draft" or not _is_optional_draft_navigation_read_error(exc):
159
+ raise
160
+ fallback_error = exc
161
+ effective_status = "published"
162
+ result = self.backend.request(
163
+ "GET",
164
+ context,
165
+ f"/navigation/detail/{navigation_item_id}",
166
+ params={"status": effective_status},
167
+ )
168
+ response = {
169
+ "profile": profile,
170
+ "ws_id": session_profile.selected_ws_id,
171
+ "navigation_item_id": navigation_item_id,
172
+ "status": effective_status,
173
+ "requested_status": requested_status,
174
+ "result": result,
175
+ }
176
+ if fallback_error is not None:
177
+ response["warnings"] = [_navigation_fallback_warning("NAVIGATION_DRAFT_DETAIL_UNAVAILABLE", fallback_error)]
178
+ response["verification"] = {"draft_readable": False, "published_fallback_used": True}
179
+ return response
118
180
 
119
181
  return self._run(profile, runner)
120
182
 
@@ -208,3 +270,20 @@ class NavigationTools(ToolBase):
208
270
  """执行内部辅助逻辑。"""
209
271
  if navigation_item_id <= 0:
210
272
  raise_tool_error(QingflowApiError.config_error("navigation_item_id must be positive"))
273
+
274
+
275
+ def _is_optional_draft_navigation_read_error(error: QingflowApiError) -> bool:
276
+ if is_auth_like_error(error):
277
+ return False
278
+ backend_code = backend_code_int(error)
279
+ return backend_code in {40002, 40027, 404} or error.http_status == 404
280
+
281
+
282
+ def _navigation_fallback_warning(code: str, error: QingflowApiError) -> JSONObject:
283
+ return {
284
+ "code": code,
285
+ "message": "draft navigation data is unavailable; returned published navigation data instead",
286
+ "backend_code": error.backend_code,
287
+ "http_status": error.http_status,
288
+ "request_id": error.request_id,
289
+ }