@qingflow-tech/qingflow-app-user-mcp 1.0.3 → 1.0.4

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 (35) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/qingflow_mcp/__init__.py +1 -1
  5. package/src/qingflow_mcp/backend_client.py +109 -0
  6. package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
  7. package/src/qingflow_mcp/builder_facade/models.py +44 -5
  8. package/src/qingflow_mcp/builder_facade/service.py +21 -8
  9. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  10. package/src/qingflow_mcp/cli/commands/app.py +47 -1
  11. package/src/qingflow_mcp/cli/commands/builder.py +7 -0
  12. package/src/qingflow_mcp/cli/commands/exports.py +111 -0
  13. package/src/qingflow_mcp/cli/commands/task.py +644 -22
  14. package/src/qingflow_mcp/cli/commands/workspace.py +64 -2
  15. package/src/qingflow_mcp/cli/context.py +3 -0
  16. package/src/qingflow_mcp/cli/formatters.py +139 -4
  17. package/src/qingflow_mcp/cli/interaction.py +72 -0
  18. package/src/qingflow_mcp/cli/main.py +5 -0
  19. package/src/qingflow_mcp/cli/terminal_ui.py +218 -0
  20. package/src/qingflow_mcp/errors.py +2 -2
  21. package/src/qingflow_mcp/export_store.py +14 -0
  22. package/src/qingflow_mcp/public_surface.py +5 -0
  23. package/src/qingflow_mcp/response_trim.py +15 -1
  24. package/src/qingflow_mcp/server.py +22 -0
  25. package/src/qingflow_mcp/server_app_builder.py +4 -0
  26. package/src/qingflow_mcp/server_app_user.py +89 -0
  27. package/src/qingflow_mcp/session_store.py +57 -6
  28. package/src/qingflow_mcp/tools/ai_builder_tools.py +59 -16
  29. package/src/qingflow_mcp/tools/auth_tools.py +26 -0
  30. package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
  31. package/src/qingflow_mcp/tools/export_tools.py +1565 -0
  32. package/src/qingflow_mcp/tools/import_tools.py +42 -2
  33. package/src/qingflow_mcp/tools/record_tools.py +145 -13
  34. package/src/qingflow_mcp/tools/resource_read_tools.py +40 -1
  35. package/src/qingflow_mcp/tools/task_context_tools.py +26 -8
@@ -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()]
@@ -2920,12 +2920,14 @@ class RecordTools(ToolBase):
2920
2920
  payload["category"] = error.category
2921
2921
  if error.backend_code is not None:
2922
2922
  payload["backend_code"] = error.backend_code
2923
+ if error.request_id is not None:
2924
+ payload["request_id"] = error.request_id
2923
2925
  if error.http_status is not None:
2924
2926
  payload["http_status"] = error.http_status
2925
2927
  return payload
2926
2928
 
2927
2929
  def _is_record_context_route_miss(self, error: QingflowApiError) -> bool:
2928
- if error.backend_code in {40002, 40027, 40038, 404}:
2930
+ if error.backend_code in {40002, 40023, 40027, 40038, 404}:
2929
2931
  return True
2930
2932
  if error.http_status == 404:
2931
2933
  return True
@@ -3059,6 +3061,107 @@ class RecordTools(ToolBase):
3059
3061
  f"({get_record_list_type_label(used_list_type)})."
3060
3062
  ]
3061
3063
 
3064
+ def _looks_like_generic_record_update_backend_failure(self, exc: QingflowApiError) -> bool:
3065
+ if exc.backend_code == 500:
3066
+ return True
3067
+ if exc.http_status is not None and exc.http_status >= 500:
3068
+ return True
3069
+ normalized_message = exc.message.strip().lower()
3070
+ return normalized_message in {"unknown error", "internal server error"}
3071
+
3072
+ def _remap_record_update_target_context_error(
3073
+ self,
3074
+ profile: str,
3075
+ context, # type: ignore[no-untyped-def]
3076
+ *,
3077
+ app_key: str,
3078
+ apply_id: int,
3079
+ exc: QingflowApiError,
3080
+ ) -> None:
3081
+ if not self._looks_like_generic_record_update_backend_failure(exc):
3082
+ return
3083
+ try:
3084
+ candidate_routes = self._candidate_update_views(profile, context, app_key)
3085
+ probes = self._probe_candidate_record_contexts(
3086
+ context,
3087
+ app_key=app_key,
3088
+ apply_id=apply_id,
3089
+ candidate_routes=candidate_routes,
3090
+ )
3091
+ except (QingflowApiError, RuntimeError):
3092
+ return
3093
+ if not probes or any(probe.readable for probe in probes):
3094
+ return
3095
+
3096
+ blocker = (
3097
+ "CURRENT_RECORD_CONTEXT_UNAVAILABLE"
3098
+ if all(probe.transport_error for probe in probes)
3099
+ else "NO_MATCHING_ACCESSIBLE_VIEW_FOR_RECORD"
3100
+ )
3101
+ recommended_next_actions = (
3102
+ [
3103
+ "Retry after the record becomes readable in the current workspace/profile context.",
3104
+ "If the issue persists, verify that the current profile still has read access to this record.",
3105
+ ]
3106
+ if blocker == "CURRENT_RECORD_CONTEXT_UNAVAILABLE"
3107
+ else [
3108
+ "Use record_get or record_list to confirm the record still exists in the current workspace.",
3109
+ "Call record_update_schema_get to inspect whether any accessible view still matches this record.",
3110
+ ]
3111
+ )
3112
+ first_error_payload = next(
3113
+ (
3114
+ cast(JSONObject, probe.error_payload)
3115
+ for probe in probes
3116
+ if isinstance(probe.error_payload, dict)
3117
+ ),
3118
+ None,
3119
+ )
3120
+ backend_code = (
3121
+ cast(int, first_error_payload.get("backend_code"))
3122
+ if isinstance(first_error_payload, dict) and isinstance(first_error_payload.get("backend_code"), int)
3123
+ else exc.backend_code
3124
+ )
3125
+ request_id = (
3126
+ _normalize_optional_text(first_error_payload.get("request_id"))
3127
+ if isinstance(first_error_payload, dict)
3128
+ else None
3129
+ ) or exc.request_id
3130
+ http_status = (
3131
+ cast(int, first_error_payload.get("http_status"))
3132
+ if isinstance(first_error_payload, dict) and isinstance(first_error_payload.get("http_status"), int)
3133
+ else exc.http_status
3134
+ )
3135
+ raise_tool_error(
3136
+ QingflowApiError(
3137
+ category="backend",
3138
+ message=(
3139
+ "Direct record edit was blocked because the current record context could not be loaded from any candidate route."
3140
+ if blocker == "CURRENT_RECORD_CONTEXT_UNAVAILABLE"
3141
+ else "Direct record edit was blocked because the target record is no longer accessible in any readable view for the current profile."
3142
+ ),
3143
+ backend_code=backend_code,
3144
+ request_id=request_id,
3145
+ http_status=http_status,
3146
+ details={
3147
+ "error_code": blocker,
3148
+ "operation": "update",
3149
+ "app_key": app_key,
3150
+ "record_id": apply_id,
3151
+ "blockers": [blocker],
3152
+ "request_route": self._request_route_payload(context),
3153
+ "view_probe_summary": [
3154
+ self._record_context_probe_summary_payload(probe)
3155
+ for probe in probes
3156
+ ],
3157
+ "recommended_next_actions": recommended_next_actions,
3158
+ "fix_hint": (
3159
+ "Confirm the target record still exists and remains visible in at least one accessible view before retrying the update."
3160
+ ),
3161
+ },
3162
+ )
3163
+ )
3164
+
3062
3165
  def _record_matches_accessible_view(
3063
3166
  self,
3064
3167
  context, # type: ignore[no-untyped-def]
@@ -6355,12 +6458,22 @@ class RecordTools(ToolBase):
6355
6458
  force_refresh_form=force_refresh_form,
6356
6459
  )
6357
6460
  self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
6358
- result = self.backend.request(
6359
- "POST",
6360
- context,
6361
- f"/app/{app_key}/apply/{normalized_apply_id}",
6362
- json_body={"role": role, "answers": normalized_answers},
6363
- )
6461
+ try:
6462
+ result = self.backend.request(
6463
+ "POST",
6464
+ context,
6465
+ f"/app/{app_key}/apply/{normalized_apply_id}",
6466
+ json_body={"role": role, "answers": normalized_answers},
6467
+ )
6468
+ except QingflowApiError as exc:
6469
+ self._remap_record_update_target_context_error(
6470
+ profile,
6471
+ context,
6472
+ app_key=app_key,
6473
+ apply_id=normalized_apply_id,
6474
+ exc=exc,
6475
+ )
6476
+ raise
6364
6477
  verification = self._verify_record_write_result(
6365
6478
  context,
6366
6479
  app_key=app_key,
@@ -6890,6 +7003,8 @@ class RecordTools(ToolBase):
6890
7003
  isinstance(payload.get("viewgraphLimit"), list)
6891
7004
  or isinstance(payload.get("viewConfig"), dict)
6892
7005
  or isinstance(payload.get("viewgraphConfig"), dict)
7006
+ or isinstance(payload.get("viewgraphQuestions"), list)
7007
+ or isinstance(payload.get("viewgraphQueIds"), list)
6893
7008
  ):
6894
7009
  config = payload
6895
7010
  else:
@@ -8794,7 +8909,13 @@ class RecordTools(ToolBase):
8794
8909
  except json.JSONDecodeError:
8795
8910
  parsed = None
8796
8911
  if isinstance(parsed, dict):
8797
- error_payload["error_code"] = parsed.get("error_code") or cast(JSONObject, parsed.get("details", {})).get("error_code") or error_payload["error_code"]
8912
+ parsed_details = parsed.get("details")
8913
+ details_payload = cast(JSONObject, parsed_details) if isinstance(parsed_details, dict) else None
8914
+ error_payload["error_code"] = (
8915
+ parsed.get("error_code")
8916
+ or (details_payload.get("error_code") if details_payload is not None else None)
8917
+ or error_payload["error_code"]
8918
+ )
8798
8919
  error_payload["message"] = parsed.get("message") or error_payload["message"]
8799
8920
  if parsed.get("backend_code") is not None:
8800
8921
  error_payload["backend_code"] = parsed.get("backend_code")
@@ -8802,6 +8923,8 @@ class RecordTools(ToolBase):
8802
8923
  error_payload["request_id"] = parsed.get("request_id")
8803
8924
  if isinstance(parsed.get("request_route"), dict):
8804
8925
  request_route = cast(JSONObject, parsed.get("request_route"))
8926
+ elif details_payload is not None and isinstance(details_payload.get("request_route"), dict):
8927
+ request_route = cast(JSONObject, details_payload.get("request_route"))
8805
8928
  response: JSONObject = {
8806
8929
  "profile": profile,
8807
8930
  "ws_id": None,
@@ -10089,11 +10212,20 @@ class RecordTools(ToolBase):
10089
10212
  """执行内部辅助逻辑。"""
10090
10213
  if not app_key:
10091
10214
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
10092
- try:
10093
- normalized_apply_id = normalize_positive_id_int(apply_id, field_name="apply_id")
10094
- except QingflowApiError:
10095
- raise_tool_error(QingflowApiError.config_error("apply_id must be positive"))
10096
- return normalized_apply_id
10215
+ return self._normalize_internal_backend_id(apply_id, field_name="apply_id")
10216
+
10217
+ def _normalize_internal_backend_id(self, value: Any, *, field_name: str) -> int:
10218
+ """Normalize backend/apply ids after the public boundary has already preserved long string ids."""
10219
+ if value in (None, "") or isinstance(value, bool):
10220
+ raise_tool_error(QingflowApiError.config_error(f"{field_name} must be positive"))
10221
+ if isinstance(value, int):
10222
+ if value <= 0:
10223
+ raise_tool_error(QingflowApiError.config_error(f"{field_name} must be positive"))
10224
+ return value
10225
+ text = stringify_backend_id(value)
10226
+ if text is None or not text.isdecimal() or int(text) <= 0:
10227
+ raise_tool_error(QingflowApiError.config_error(f"{field_name} must be positive"))
10228
+ return int(text)
10097
10229
 
10098
10230
  def _validate_record_write(self, app_key: str, answers: list[JSONObject], apply_id: int | None = None) -> None:
10099
10231
  """执行内部辅助逻辑。"""
@@ -96,6 +96,9 @@ class ResourceReadTools(ToolBase):
96
96
  )
97
97
  )
98
98
  if system_view is not None:
99
+ export_capability = _view_export_capability_payload(
100
+ supported=_export_supported_for_view_type(system_view["view_type"])
101
+ )
99
102
  return self._run(
100
103
  profile,
101
104
  lambda session_profile, _context: {
@@ -106,11 +109,17 @@ class ResourceReadTools(ToolBase):
106
109
  {
107
110
  "code": "VIEW_APP_KEY_UNRESOLVED",
108
111
  "message": f"view_get could not resolve app_key for system view `{view_id}`; keep using the app_key from the parent app context.",
109
- }
112
+ },
113
+ {
114
+ "code": "VIEW_EXPORT_APP_CONTEXT_REQUIRED",
115
+ "message": f"view_get supports exporting `{view_id}`, but the export call still needs the parent app context `app_key`.",
116
+ },
110
117
  ],
111
118
  "verification": {
112
119
  "view_exists": True,
113
120
  "descriptor_only": True,
121
+ "export_route_supported": export_capability["supported"],
122
+ "export_permission_verified": export_capability["permission_verified"],
114
123
  },
115
124
  "data": {
116
125
  "app_key": None,
@@ -120,11 +129,13 @@ class ResourceReadTools(ToolBase):
120
129
  "view_type": system_view["view_type"],
121
130
  "visible_columns": [],
122
131
  "analysis_supported": system_view["analysis_supported"],
132
+ "export_capability": export_capability,
123
133
  },
124
134
  },
125
135
  )
126
136
 
127
137
  def runner(session_profile, context):
138
+ raw_view_type = None
128
139
  warnings: list[JSONObject] = []
129
140
  verification = {
130
141
  "view_exists": True,
@@ -150,6 +161,11 @@ class ResourceReadTools(ToolBase):
150
161
  str(base_info.get("viewgraphType") or "").strip()
151
162
  or str(config.get("viewgraphType") or config.get("viewType") or "").strip()
152
163
  )
164
+ export_capability = _view_export_capability_payload(
165
+ supported=_export_supported_for_view_type(raw_view_type or None)
166
+ )
167
+ verification["export_route_supported"] = export_capability["supported"]
168
+ verification["export_permission_verified"] = export_capability["permission_verified"]
153
169
  resolved_app_key = str(base_info.get("appKey") or config.get("appKey") or "").strip() or None
154
170
  if not resolved_app_key:
155
171
  resolved_app_key = self._resolve_app_key_from_view_form(context=context, view_key=view_key)
@@ -165,6 +181,13 @@ class ResourceReadTools(ToolBase):
165
181
  "message": f"view_get could not resolve app_key for `{view_id}` from view metadata; keep using the app_key from the parent app or portal context.",
166
182
  }
167
183
  )
184
+ if export_capability["supported"]:
185
+ warnings.append(
186
+ {
187
+ "code": "VIEW_EXPORT_APP_CONTEXT_REQUIRED",
188
+ "message": f"view_get supports exporting `{view_id}`, but the export call still needs an explicit `app_key` from the parent app context.",
189
+ }
190
+ )
168
191
  return {
169
192
  "profile": profile,
170
193
  "ws_id": session_profile.selected_ws_id,
@@ -186,6 +209,7 @@ class ResourceReadTools(ToolBase):
186
209
  if str(item.get("queTitle") or item.get("title") or "").strip()
187
210
  ],
188
211
  "analysis_supported": _analysis_supported_for_view_type(raw_view_type or None),
212
+ "export_capability": export_capability,
189
213
  },
190
214
  }
191
215
 
@@ -464,6 +488,21 @@ def _lookup_system_view_descriptor(view_id: str) -> dict[str, Any] | None:
464
488
  return None
465
489
 
466
490
 
491
+ def _view_export_capability_payload(*, supported: bool) -> JSONObject:
492
+ return {
493
+ "supported": supported,
494
+ "tool": "record_export_start",
495
+ "format": "xlsx",
496
+ "async": True,
497
+ "requires_app_key": True,
498
+ "permission_verified": False,
499
+ }
500
+
501
+
502
+ def _export_supported_for_view_type(view_type: str | None) -> bool:
503
+ return _analysis_supported_for_view_type(view_type)
504
+
505
+
467
506
  def _normalize_view_type(view_type: Any) -> str | None:
468
507
  value = str(view_type or "").strip()
469
508
  if not value:
@@ -1762,6 +1762,7 @@ class TaskContextTools(ToolBase):
1762
1762
  "apply_time": record.get("apply_time"),
1763
1763
  "last_update_time": record.get("last_update_time"),
1764
1764
  "core_fields": self._task_record_core_fields(record.get("answers") or []),
1765
+ "all_fields": self._task_record_all_fields(record.get("answers") or []),
1765
1766
  },
1766
1767
  "available_actions": available_actions,
1767
1768
  "editable_fields": [
@@ -1921,9 +1922,21 @@ class TaskContextTools(ToolBase):
1921
1922
  return None
1922
1923
 
1923
1924
  def _task_record_core_fields(self, answers: Any, *, limit: int = 12) -> dict[str, Any]:
1925
+ return self._task_record_field_map(answers, limit=limit, truncate_text=160)
1926
+
1927
+ def _task_record_all_fields(self, answers: Any) -> dict[str, Any]:
1928
+ return self._task_record_field_map(answers, limit=None, truncate_text=None)
1929
+
1930
+ def _task_record_field_map(
1931
+ self,
1932
+ answers: Any,
1933
+ *,
1934
+ limit: int | None,
1935
+ truncate_text: int | None,
1936
+ ) -> dict[str, Any]:
1924
1937
  if not isinstance(answers, list):
1925
1938
  return {}
1926
- core_fields: dict[str, Any] = {}
1939
+ field_map: dict[str, Any] = {}
1927
1940
  for answer in answers:
1928
1941
  if not isinstance(answer, dict):
1929
1942
  continue
@@ -1943,19 +1956,24 @@ class TaskContextTools(ToolBase):
1943
1956
  value = values[0] if len(values) == 1 else values
1944
1957
  if value in (None, "", []):
1945
1958
  continue
1946
- core_fields[str(title)] = self._compact_task_value(value)
1947
- if len(core_fields) >= limit:
1959
+ field_map[str(title)] = self._compact_task_value(value, truncate_text=truncate_text)
1960
+ if limit is not None and len(field_map) >= limit:
1948
1961
  break
1949
- return core_fields
1962
+ return field_map
1950
1963
 
1951
- def _compact_task_value(self, value: Any) -> Any:
1964
+ def _compact_task_value(self, value: Any, *, truncate_text: int | None = 160) -> Any:
1952
1965
  if isinstance(value, list):
1953
- return [self._compact_task_value(item) for item in value[:8]]
1966
+ items = [self._compact_task_value(item, truncate_text=truncate_text) for item in value]
1967
+ if truncate_text is not None:
1968
+ return items[:8]
1969
+ return items
1954
1970
  text = re.sub(r"<[^>]+>", " ", str(value))
1955
1971
  text = re.sub(r"\s+", " ", text).strip()
1956
- if len(text) <= 160:
1972
+ if truncate_text is None or len(text) <= truncate_text:
1957
1973
  return text
1958
- return text[:157].rstrip() + "..."
1974
+ if truncate_text <= 3:
1975
+ return text[:truncate_text]
1976
+ return text[: truncate_text - 3].rstrip() + "..."
1959
1977
 
1960
1978
  def _compact_task_editable_field(self, field: dict[str, Any], update_schema: dict[str, Any]) -> dict[str, Any]:
1961
1979
  payload_template = update_schema.get("payload_template") if isinstance(update_schema.get("payload_template"), dict) else {}