@josephyan/qingflow-mcp 0.1.0-beta.2

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