@josephyan/qingflow-app-builder-mcp 0.1.0-beta.10

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 (55) hide show
  1. package/README.md +21 -0
  2. package/docs/local-agent-install.md +228 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow-app-builder-mcp.mjs +7 -0
  5. package/npm/lib/runtime.mjs +146 -0
  6. package/npm/scripts/postinstall.mjs +12 -0
  7. package/package.json +33 -0
  8. package/pyproject.toml +64 -0
  9. package/qingflow-app-builder-mcp +15 -0
  10. package/src/qingflow_mcp/__init__.py +5 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +336 -0
  13. package/src/qingflow_mcp/config.py +182 -0
  14. package/src/qingflow_mcp/errors.py +66 -0
  15. package/src/qingflow_mcp/json_types.py +18 -0
  16. package/src/qingflow_mcp/list_type_labels.py +52 -0
  17. package/src/qingflow_mcp/server.py +70 -0
  18. package/src/qingflow_mcp/server_app_builder.py +352 -0
  19. package/src/qingflow_mcp/server_app_user.py +334 -0
  20. package/src/qingflow_mcp/session_store.py +249 -0
  21. package/src/qingflow_mcp/solution/__init__.py +6 -0
  22. package/src/qingflow_mcp/solution/build_assembly_store.py +137 -0
  23. package/src/qingflow_mcp/solution/compiler/__init__.py +265 -0
  24. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  25. package/src/qingflow_mcp/solution/compiler/form_compiler.py +456 -0
  26. package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
  27. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  28. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  29. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  30. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  31. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +134 -0
  32. package/src/qingflow_mcp/solution/design_session.py +222 -0
  33. package/src/qingflow_mcp/solution/design_store.py +100 -0
  34. package/src/qingflow_mcp/solution/executor.py +2065 -0
  35. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  36. package/src/qingflow_mcp/solution/run_store.py +221 -0
  37. package/src/qingflow_mcp/solution/spec_models.py +853 -0
  38. package/src/qingflow_mcp/tools/__init__.py +1 -0
  39. package/src/qingflow_mcp/tools/app_tools.py +406 -0
  40. package/src/qingflow_mcp/tools/approval_tools.py +498 -0
  41. package/src/qingflow_mcp/tools/auth_tools.py +514 -0
  42. package/src/qingflow_mcp/tools/base.py +81 -0
  43. package/src/qingflow_mcp/tools/directory_tools.py +476 -0
  44. package/src/qingflow_mcp/tools/file_tools.py +375 -0
  45. package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
  46. package/src/qingflow_mcp/tools/package_tools.py +198 -0
  47. package/src/qingflow_mcp/tools/portal_tools.py +100 -0
  48. package/src/qingflow_mcp/tools/qingbi_report_tools.py +235 -0
  49. package/src/qingflow_mcp/tools/record_tools.py +4307 -0
  50. package/src/qingflow_mcp/tools/role_tools.py +94 -0
  51. package/src/qingflow_mcp/tools/solution_tools.py +2684 -0
  52. package/src/qingflow_mcp/tools/task_tools.py +692 -0
  53. package/src/qingflow_mcp/tools/view_tools.py +280 -0
  54. package/src/qingflow_mcp/tools/workflow_tools.py +238 -0
  55. package/src/qingflow_mcp/tools/workspace_tools.py +170 -0
@@ -0,0 +1,498 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from ..config import DEFAULT_PROFILE
8
+ from ..errors import QingflowApiError, raise_tool_error
9
+ from ..json_types import JSONObject
10
+ from ..list_type_labels import get_record_list_type_label
11
+ from .base import ToolBase
12
+
13
+
14
+ class ApprovalTools(ToolBase):
15
+ def __init__(self, sessions, backend) -> None: # type: ignore[no-untyped-def]
16
+ super().__init__(sessions, backend)
17
+ self._form_id_cache: dict[str, int] = {}
18
+
19
+ def register(self, mcp: FastMCP) -> None:
20
+ @mcp.tool()
21
+ def record_comment_add(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0, payload: dict[str, Any] | None = None) -> dict[str, Any]:
22
+ return self.record_comment_add(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
23
+
24
+ @mcp.tool()
25
+ def record_comment_list(
26
+ profile: str = DEFAULT_PROFILE,
27
+ app_key: str = "",
28
+ apply_id: int = 0,
29
+ page_size: int = 20,
30
+ list_type: int | None = None,
31
+ page_num: int | None = 1,
32
+ ) -> dict[str, Any]:
33
+ return self.record_comment_list(
34
+ profile=profile,
35
+ app_key=app_key,
36
+ apply_id=apply_id,
37
+ page_size=page_size,
38
+ list_type=list_type,
39
+ page_num=page_num,
40
+ )
41
+
42
+ @mcp.tool()
43
+ def record_comment_mention_candidates(
44
+ profile: str = DEFAULT_PROFILE,
45
+ app_key: str = "",
46
+ apply_id: int = 0,
47
+ page_size: int = 20,
48
+ page_num: int = 1,
49
+ list_type: int | None = None,
50
+ keyword: str | None = None,
51
+ ) -> dict[str, Any]:
52
+ return self.record_comment_mention_candidates(
53
+ profile=profile,
54
+ app_key=app_key,
55
+ apply_id=apply_id,
56
+ page_size=page_size,
57
+ page_num=page_num,
58
+ list_type=list_type,
59
+ keyword=keyword,
60
+ )
61
+
62
+ @mcp.tool()
63
+ def record_comment_mark_read(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0) -> dict[str, Any]:
64
+ return self.record_comment_mark_read(profile=profile, app_key=app_key, apply_id=apply_id)
65
+
66
+ @mcp.tool()
67
+ def record_comment_stats(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0) -> dict[str, Any]:
68
+ return self.record_comment_stats(profile=profile, app_key=app_key, apply_id=apply_id)
69
+
70
+ @mcp.tool(description=self._high_risk_tool_description(operation="approve", target="workflow record"))
71
+ def record_approve(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0, payload: dict[str, Any] | None = None) -> dict[str, Any]:
72
+ return self.record_approve(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
73
+
74
+ @mcp.tool(description=self._high_risk_tool_description(operation="reject", target="workflow record"))
75
+ def record_reject(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0, payload: dict[str, Any] | None = None) -> dict[str, Any]:
76
+ return self.record_reject(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
77
+
78
+ @mcp.tool()
79
+ def record_rollback_candidates(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0, audit_node_id: int = 0) -> dict[str, Any]:
80
+ return self.record_rollback_candidates(profile=profile, app_key=app_key, apply_id=apply_id, audit_node_id=audit_node_id)
81
+
82
+ @mcp.tool()
83
+ def record_rollback(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0, payload: dict[str, Any] | None = None) -> dict[str, Any]:
84
+ return self.record_rollback(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
85
+
86
+ @mcp.tool()
87
+ def record_transfer(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0, payload: dict[str, Any] | None = None) -> dict[str, Any]:
88
+ return self.record_transfer(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
89
+
90
+ @mcp.tool()
91
+ def record_transfer_candidates(
92
+ profile: str = DEFAULT_PROFILE,
93
+ app_key: str = "",
94
+ apply_id: int = 0,
95
+ page_size: int = 20,
96
+ page_num: int = 1,
97
+ audit_node_id: int = 0,
98
+ keyword: str | None = None,
99
+ ) -> dict[str, Any]:
100
+ return self.record_transfer_candidates(
101
+ profile=profile,
102
+ app_key=app_key,
103
+ apply_id=apply_id,
104
+ page_size=page_size,
105
+ page_num=page_num,
106
+ audit_node_id=audit_node_id,
107
+ keyword=keyword,
108
+ )
109
+
110
+ @mcp.tool()
111
+ def record_reassign_get(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0) -> dict[str, Any]:
112
+ return self.record_reassign_get(profile=profile, app_key=app_key, apply_id=apply_id)
113
+
114
+ @mcp.tool()
115
+ def record_reassign(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0, payload: dict[str, Any] | None = None) -> dict[str, Any]:
116
+ return self.record_reassign(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
117
+
118
+ @mcp.tool()
119
+ def record_countersign_candidates(
120
+ profile: str = DEFAULT_PROFILE,
121
+ app_key: str = "",
122
+ apply_id: int = 0,
123
+ page_size: int = 20,
124
+ page_num: int = 1,
125
+ audit_node_id: int = 0,
126
+ search_key: str | None = None,
127
+ ) -> dict[str, Any]:
128
+ return self.record_countersign_candidates(
129
+ profile=profile,
130
+ app_key=app_key,
131
+ apply_id=apply_id,
132
+ page_size=page_size,
133
+ page_num=page_num,
134
+ audit_node_id=audit_node_id,
135
+ search_key=search_key,
136
+ )
137
+
138
+ @mcp.tool()
139
+ def record_countersign(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0, payload: dict[str, Any] | None = None) -> dict[str, Any]:
140
+ return self.record_countersign(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
141
+
142
+ def record_comment_add(self, *, profile: str, app_key: str, apply_id: int, payload: dict[str, Any]) -> dict[str, Any]:
143
+ self._require_app_and_apply(app_key, apply_id)
144
+ self._validate_comment_payload(payload)
145
+
146
+ def runner(session_profile, context):
147
+ result = self.backend.request("POST", context, f"/app/{app_key}/apply/{apply_id}/comment", json_body=payload)
148
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "apply_id": apply_id, "result": result}
149
+
150
+ return self._run(profile, runner)
151
+
152
+ def record_comment_list(self, *, profile: str, app_key: str, apply_id: int, page_size: int = 20, list_type: int | None = None, page_num: int | None = 1) -> dict[str, Any]:
153
+ self._require_app_and_apply(app_key, apply_id)
154
+
155
+ def runner(session_profile, context):
156
+ params: dict[str, Any] = {"pageSize": page_size}
157
+ if list_type is not None:
158
+ params["listType"] = list_type
159
+ if page_num is not None:
160
+ params["pageNum"] = page_num
161
+ result = self.backend.request("GET", context, f"/app/{app_key}/apply/{apply_id}/comment", params=params)
162
+ return {
163
+ "profile": profile,
164
+ "ws_id": session_profile.selected_ws_id,
165
+ "app_key": app_key,
166
+ "apply_id": apply_id,
167
+ "list_type": list_type,
168
+ "list_type_label": get_record_list_type_label(list_type),
169
+ "page": result,
170
+ }
171
+
172
+ return self._run(profile, runner)
173
+
174
+ def record_comment_mention_candidates(
175
+ self,
176
+ *,
177
+ profile: str,
178
+ app_key: str,
179
+ apply_id: int,
180
+ page_size: int = 20,
181
+ page_num: int = 1,
182
+ list_type: int | None = None,
183
+ keyword: str | None = None,
184
+ ) -> dict[str, Any]:
185
+ self._require_app_and_apply(app_key, apply_id)
186
+
187
+ def runner(session_profile, context):
188
+ params: dict[str, Any] = {"pageSize": page_size, "pageNum": page_num}
189
+ if list_type is not None:
190
+ params["listType"] = list_type
191
+ if keyword:
192
+ params["keyword"] = keyword
193
+ result = self.backend.request("GET", context, f"/app/{app_key}/apply/{apply_id}/comment/member", params=params)
194
+ return {
195
+ "profile": profile,
196
+ "ws_id": session_profile.selected_ws_id,
197
+ "app_key": app_key,
198
+ "apply_id": apply_id,
199
+ "list_type": list_type,
200
+ "list_type_label": get_record_list_type_label(list_type),
201
+ "page": result,
202
+ }
203
+
204
+ return self._run(profile, runner)
205
+
206
+ def record_comment_mark_read(self, *, profile: str, app_key: str, apply_id: int) -> dict[str, Any]:
207
+ self._require_app_and_apply(app_key, apply_id)
208
+
209
+ def runner(session_profile, context):
210
+ result = self.backend.request("POST", context, f"/app/{app_key}/apply/{apply_id}/comment/read")
211
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "apply_id": apply_id, "result": result}
212
+
213
+ return self._run(profile, runner)
214
+
215
+ def record_comment_stats(self, *, profile: str, app_key: str, apply_id: int) -> dict[str, Any]:
216
+ self._require_app_and_apply(app_key, apply_id)
217
+
218
+ def runner(session_profile, context):
219
+ result = self.backend.request("GET", context, f"/app/{app_key}/apply/{apply_id}/comment/statistic")
220
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "apply_id": apply_id, "result": result}
221
+
222
+ return self._run(profile, runner)
223
+
224
+ def record_approve(self, *, profile: str, app_key: str, apply_id: int, payload: dict[str, Any]) -> dict[str, Any]:
225
+ self._require_app_and_apply(app_key, apply_id)
226
+ body = self._require_dict(payload)
227
+
228
+ def runner(session_profile, context):
229
+ approval_body = self._normalize_approval_payload(profile, context, app_key, apply_id, body)
230
+ result = self.backend.request("POST", context, "/workflow/engine/approval/approve", json_body=approval_body)
231
+ return {
232
+ "profile": profile,
233
+ "ws_id": session_profile.selected_ws_id,
234
+ "app_key": app_key,
235
+ "apply_id": apply_id,
236
+ "form_id": approval_body["formId"],
237
+ "node_id": approval_body["nodeId"],
238
+ "result": result,
239
+ "request_route": self._request_route_payload(context),
240
+ }
241
+
242
+ return self._run(profile, runner)
243
+
244
+ def record_reject(self, *, profile: str, app_key: str, apply_id: int, payload: dict[str, Any]) -> dict[str, Any]:
245
+ self._require_app_and_apply(app_key, apply_id)
246
+ body = self._require_dict(payload)
247
+
248
+ def runner(session_profile, context):
249
+ approval_body = self._normalize_approval_payload(profile, context, app_key, apply_id, body)
250
+ result = self.backend.request("POST", context, "/workflow/engine/approval/reject", json_body=approval_body)
251
+ return {
252
+ "profile": profile,
253
+ "ws_id": session_profile.selected_ws_id,
254
+ "app_key": app_key,
255
+ "apply_id": apply_id,
256
+ "form_id": approval_body["formId"],
257
+ "node_id": approval_body["nodeId"],
258
+ "result": result,
259
+ "request_route": self._request_route_payload(context),
260
+ }
261
+
262
+ return self._run(profile, runner)
263
+
264
+ def record_rollback_candidates(self, *, profile: str, app_key: str, apply_id: int, audit_node_id: int) -> dict[str, Any]:
265
+ self._require_app_and_apply(app_key, apply_id)
266
+ if audit_node_id <= 0:
267
+ raise_tool_error(QingflowApiError.config_error("audit_node_id must be positive"))
268
+
269
+ def runner(session_profile, context):
270
+ result = self.backend.request(
271
+ "GET",
272
+ context,
273
+ f"/app/{app_key}/apply/{apply_id}/revertNode",
274
+ params={"auditNodeId": audit_node_id},
275
+ )
276
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "apply_id": apply_id, "result": result}
277
+
278
+ return self._run(profile, runner)
279
+
280
+ def record_rollback(self, *, profile: str, app_key: str, apply_id: int, payload: dict[str, Any]) -> dict[str, Any]:
281
+ self._require_app_and_apply(app_key, apply_id)
282
+ body = self._require_dict(payload)
283
+ self._validate_audit_payload(body)
284
+
285
+ def runner(session_profile, context):
286
+ result = self.backend.request("POST", context, f"/app/{app_key}/apply/{apply_id}/rollback", json_body=body)
287
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "apply_id": apply_id, "result": result}
288
+
289
+ return self._run(profile, runner)
290
+
291
+ def record_transfer(self, *, profile: str, app_key: str, apply_id: int, payload: dict[str, Any]) -> dict[str, Any]:
292
+ self._require_app_and_apply(app_key, apply_id)
293
+ body = self._require_dict(payload)
294
+ self._validate_audit_payload(body, require_uid=True)
295
+
296
+ def runner(session_profile, context):
297
+ result = self.backend.request("POST", context, f"/app/{app_key}/apply/{apply_id}/transfer", json_body=body)
298
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "apply_id": apply_id, "result": result}
299
+
300
+ return self._run(profile, runner)
301
+
302
+ def record_transfer_candidates(
303
+ self,
304
+ *,
305
+ profile: str,
306
+ app_key: str,
307
+ apply_id: int,
308
+ page_size: int = 20,
309
+ page_num: int = 1,
310
+ audit_node_id: int = 0,
311
+ keyword: str | None = None,
312
+ ) -> dict[str, Any]:
313
+ self._require_app_and_apply(app_key, apply_id)
314
+ if audit_node_id <= 0:
315
+ raise_tool_error(QingflowApiError.config_error("audit_node_id must be positive"))
316
+
317
+ def runner(session_profile, context):
318
+ params: dict[str, Any] = {"pageSize": page_size, "pageNum": page_num, "auditNodeId": audit_node_id}
319
+ if keyword:
320
+ params["keyword"] = keyword
321
+ result = self.backend.request("GET", context, f"/app/{app_key}/apply/{apply_id}/transfer/member", params=params)
322
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "apply_id": apply_id, "page": result}
323
+
324
+ return self._run(profile, runner)
325
+
326
+ def record_reassign_get(self, *, profile: str, app_key: str, apply_id: int) -> dict[str, Any]:
327
+ self._require_app_and_apply(app_key, apply_id)
328
+
329
+ def runner(session_profile, context):
330
+ result = self.backend.request("GET", context, f"/app/{app_key}/apply/{apply_id}/reassign")
331
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "apply_id": apply_id, "result": result}
332
+
333
+ return self._run(profile, runner)
334
+
335
+ def record_reassign(self, *, profile: str, app_key: str, apply_id: int, payload: dict[str, Any]) -> dict[str, Any]:
336
+ self._require_app_and_apply(app_key, apply_id)
337
+ body = self._require_dict(payload)
338
+
339
+ def runner(session_profile, context):
340
+ result = self.backend.request("POST", context, f"/app/{app_key}/apply/{apply_id}/reassign", json_body=body)
341
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "apply_id": apply_id, "result": result}
342
+
343
+ return self._run(profile, runner)
344
+
345
+ def record_countersign_candidates(
346
+ self,
347
+ *,
348
+ profile: str,
349
+ app_key: str,
350
+ apply_id: int,
351
+ page_size: int = 20,
352
+ page_num: int = 1,
353
+ audit_node_id: int = 0,
354
+ search_key: str | None = None,
355
+ ) -> dict[str, Any]:
356
+ self._require_app_and_apply(app_key, apply_id)
357
+ if audit_node_id <= 0:
358
+ raise_tool_error(QingflowApiError.config_error("audit_node_id must be positive"))
359
+
360
+ def runner(session_profile, context):
361
+ params: dict[str, Any] = {"pageSize": page_size, "pageNum": page_num, "auditNodeId": audit_node_id}
362
+ if search_key:
363
+ params["searchKey"] = search_key
364
+ result = self.backend.request("GET", context, f"/app/{app_key}/apply/{apply_id}/countersign/member", params=params)
365
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "apply_id": apply_id, "page": result}
366
+
367
+ return self._run(profile, runner)
368
+
369
+ def record_countersign(self, *, profile: str, app_key: str, apply_id: int, payload: dict[str, Any]) -> dict[str, Any]:
370
+ self._require_app_and_apply(app_key, apply_id)
371
+ body = self._require_dict(payload)
372
+ self._validate_countersign_payload(body)
373
+
374
+ def runner(session_profile, context):
375
+ result = self.backend.request("POST", context, f"/app/{app_key}/apply/{apply_id}/countersign", json_body=body)
376
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "apply_id": apply_id, "result": result}
377
+
378
+ return self._run(profile, runner)
379
+
380
+ def _require_app_and_apply(self, app_key: str, apply_id: int) -> None:
381
+ if not app_key:
382
+ raise_tool_error(QingflowApiError.config_error("app_key is required"))
383
+ if apply_id <= 0:
384
+ raise_tool_error(QingflowApiError.config_error("apply_id must be positive"))
385
+
386
+ def _validate_comment_payload(self, payload: dict[str, Any]) -> None:
387
+ comment_detail = payload.get("commentDetail")
388
+ if not isinstance(comment_detail, dict):
389
+ raise_tool_error(QingflowApiError.config_error("payload.commentDetail must be an object"))
390
+ if not comment_detail.get("commentMsg"):
391
+ raise_tool_error(QingflowApiError.config_error("payload.commentDetail.commentMsg is required"))
392
+
393
+ def _normalize_approval_payload(self, profile: str, context, app_key: str, apply_id: int, payload: dict[str, Any]) -> JSONObject: # type: ignore[no-untyped-def]
394
+ body: JSONObject = dict(payload)
395
+ self._normalize_alias(body, "auditFeedback", "audit_feedback")
396
+ self._normalize_alias(body, "uploadFileSize", "upload_file_size")
397
+ self._normalize_alias(body, "handSignImageUrl", "hand_sign_image_url")
398
+ self._normalize_alias(body, "beingSaveSignature", "being_save_signature")
399
+ self._normalize_alias(body, "applyId", "apply_id")
400
+ self._normalize_alias(body, "formId", "form_id")
401
+
402
+ node_id = self._extract_node_id(body)
403
+ body["nodeId"] = node_id
404
+ body["applyId"] = self._match_or_fill_int(body, field_name="applyId", expected_value=apply_id)
405
+ body["formId"] = self._resolve_form_id(profile, context, app_key, explicit_form_id=body.get("formId"))
406
+
407
+ self._validate_approval_payload(body)
408
+ return body
409
+
410
+ def _extract_node_id(self, payload: JSONObject) -> int:
411
+ node_id = payload.get("nodeId")
412
+ audit_node_id = payload.pop("auditNodeId", None)
413
+ if node_id is None:
414
+ node_id = audit_node_id
415
+ elif audit_node_id is not None and node_id != audit_node_id:
416
+ raise_tool_error(QingflowApiError.config_error("payload.nodeId and payload.auditNodeId must match when both are provided"))
417
+ if not isinstance(node_id, int) or node_id <= 0:
418
+ raise_tool_error(QingflowApiError.config_error("payload.nodeId or payload.auditNodeId must be a positive integer"))
419
+ return node_id
420
+
421
+ def _resolve_form_id(self, profile: str, context, app_key: str, *, explicit_form_id: Any | None) -> int: # type: ignore[no-untyped-def]
422
+ if explicit_form_id is not None:
423
+ if not isinstance(explicit_form_id, int) or explicit_form_id <= 0:
424
+ raise_tool_error(QingflowApiError.config_error("payload.formId must be a positive integer"))
425
+ form_id = self._get_form_id(profile, context, app_key)
426
+ if form_id != explicit_form_id:
427
+ raise_tool_error(
428
+ QingflowApiError.config_error(
429
+ f"payload.formId={explicit_form_id} does not match app_key '{app_key}' formId={form_id}"
430
+ )
431
+ )
432
+ return explicit_form_id
433
+ return self._get_form_id(profile, context, app_key)
434
+
435
+ def _get_form_id(self, profile: str, context, app_key: str) -> int: # type: ignore[no-untyped-def]
436
+ cache_key = f"{profile}:{app_key}"
437
+ cached = self._form_id_cache.get(cache_key)
438
+ if cached is not None:
439
+ return cached
440
+ result = self.backend.request("GET", context, f"/app/{app_key}/baseInfo")
441
+ form_id = result.get("formId") if isinstance(result, dict) else None
442
+ if not isinstance(form_id, int) or form_id <= 0:
443
+ raise_tool_error(QingflowApiError.config_error(f"cannot resolve formId for app_key '{app_key}'"))
444
+ self._form_id_cache[cache_key] = form_id
445
+ return form_id
446
+
447
+ def _match_or_fill_int(self, payload: JSONObject, *, field_name: str, expected_value: int) -> int:
448
+ current = payload.get(field_name)
449
+ if current is None:
450
+ return expected_value
451
+ if not isinstance(current, int) or current <= 0:
452
+ raise_tool_error(QingflowApiError.config_error(f"payload.{field_name} must be a positive integer"))
453
+ if current != expected_value:
454
+ raise_tool_error(QingflowApiError.config_error(f"payload.{field_name}={current} does not match apply_id={expected_value}"))
455
+ return current
456
+
457
+ def _normalize_alias(self, payload: JSONObject, canonical_key: str, alias_key: str) -> None:
458
+ alias_value = payload.pop(alias_key, None)
459
+ if canonical_key not in payload and alias_value is not None:
460
+ payload[canonical_key] = alias_value
461
+ elif alias_value is not None and payload.get(canonical_key) != alias_value:
462
+ raise_tool_error(QingflowApiError.config_error(f"payload.{canonical_key} and payload.{alias_key} must match when both are provided"))
463
+
464
+ def _validate_approval_payload(self, payload: dict[str, Any]) -> None:
465
+ self._reject_unsupported_fields(payload)
466
+ if not isinstance(payload.get("formId"), int) or payload["formId"] <= 0:
467
+ raise_tool_error(QingflowApiError.config_error("payload.formId must be a positive integer"))
468
+ if not isinstance(payload.get("applyId"), int) or payload["applyId"] <= 0:
469
+ raise_tool_error(QingflowApiError.config_error("payload.applyId must be a positive integer"))
470
+ if not isinstance(payload.get("nodeId"), int) or payload["nodeId"] <= 0:
471
+ raise_tool_error(QingflowApiError.config_error("payload.nodeId must be a positive integer"))
472
+
473
+ def _validate_audit_payload(self, payload: dict[str, Any], *, require_uid: bool = False) -> None:
474
+ self._reject_unsupported_fields(payload)
475
+ if require_uid and not payload.get("uid"):
476
+ raise_tool_error(QingflowApiError.config_error("payload.uid is required"))
477
+
478
+ def _validate_countersign_payload(self, payload: dict[str, Any]) -> None:
479
+ self._reject_unsupported_fields(payload)
480
+ members = payload.get("countersignMembers")
481
+ if not isinstance(members, list) or not members:
482
+ raise_tool_error(QingflowApiError.config_error("payload.countersignMembers must be a non-empty array"))
483
+
484
+ def _reject_unsupported_fields(self, payload: dict[str, Any]) -> None:
485
+ if payload.get("handSignImageUrl"):
486
+ raise_tool_error(QingflowApiError.not_supported("NOT_SUPPORTED_IN_V1: handSignImageUrl is not supported"))
487
+
488
+ def _request_route_payload(self, context) -> JSONObject: # type: ignore[no-untyped-def]
489
+ describe_route = getattr(self.backend, "describe_route", None)
490
+ if callable(describe_route):
491
+ payload = describe_route(context)
492
+ if isinstance(payload, dict):
493
+ return payload
494
+ return {
495
+ "base_url": context.base_url,
496
+ "qf_version": context.qf_version,
497
+ "qf_version_source": getattr(context, "qf_version_source", None) or ("context" if getattr(context, "qf_version", None) else "unknown"),
498
+ }