@josephyan/qingflow-app-builder-mcp 0.2.0-beta.7 → 0.2.0-beta.71

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 (70) hide show
  1. package/README.md +5 -3
  2. package/docs/local-agent-install.md +21 -5
  3. package/npm/bin/qingflow-app-builder-mcp.mjs +1 -1
  4. package/npm/lib/runtime.mjs +168 -12
  5. package/package.json +1 -1
  6. package/pyproject.toml +4 -1
  7. package/skills/qingflow-app-builder/SKILL.md +155 -22
  8. package/skills/qingflow-app-builder/references/create-app.md +51 -21
  9. package/skills/qingflow-app-builder/references/environments.md +1 -1
  10. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
  11. package/skills/qingflow-app-builder/references/gotchas.md +28 -1
  12. package/skills/qingflow-app-builder/references/solution-playbooks.md +14 -12
  13. package/skills/qingflow-app-builder/references/tool-selection.md +47 -19
  14. package/skills/qingflow-app-builder/references/update-flow.md +112 -25
  15. package/skills/qingflow-app-builder/references/update-layout.md +11 -24
  16. package/skills/qingflow-app-builder/references/update-schema.md +1 -23
  17. package/skills/qingflow-app-builder/references/update-views.md +87 -21
  18. package/skills/qingflow-app-builder-code-integrations/SKILL.md +137 -0
  19. package/skills/qingflow-app-builder-code-integrations/agents/openai.yaml +4 -0
  20. package/skills/qingflow-app-builder-code-integrations/references/code-block.md +66 -0
  21. package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +77 -0
  22. package/src/qingflow_mcp/__init__.py +1 -1
  23. package/src/qingflow_mcp/backend_client.py +210 -0
  24. package/src/qingflow_mcp/builder_facade/models.py +1252 -3
  25. package/src/qingflow_mcp/builder_facade/service.py +11367 -2389
  26. package/src/qingflow_mcp/cli/__init__.py +1 -0
  27. package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
  28. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  29. package/src/qingflow_mcp/cli/commands/auth.py +78 -0
  30. package/src/qingflow_mcp/cli/commands/builder.py +515 -0
  31. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  32. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  33. package/src/qingflow_mcp/cli/commands/record.py +304 -0
  34. package/src/qingflow_mcp/cli/commands/task.py +89 -0
  35. package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
  36. package/src/qingflow_mcp/cli/context.py +48 -0
  37. package/src/qingflow_mcp/cli/formatters.py +355 -0
  38. package/src/qingflow_mcp/cli/json_io.py +50 -0
  39. package/src/qingflow_mcp/cli/main.py +149 -0
  40. package/src/qingflow_mcp/config.py +39 -0
  41. package/src/qingflow_mcp/import_store.py +121 -0
  42. package/src/qingflow_mcp/list_type_labels.py +24 -0
  43. package/src/qingflow_mcp/response_trim.py +668 -0
  44. package/src/qingflow_mcp/server.py +160 -18
  45. package/src/qingflow_mcp/server_app_builder.py +275 -68
  46. package/src/qingflow_mcp/server_app_user.py +219 -191
  47. package/src/qingflow_mcp/session_store.py +41 -1
  48. package/src/qingflow_mcp/solution/compiler/form_compiler.py +43 -4
  49. package/src/qingflow_mcp/solution/compiler/icon_utils.py +119 -45
  50. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +41 -2
  51. package/src/qingflow_mcp/solution/executor.py +107 -11
  52. package/src/qingflow_mcp/solution/spec_models.py +2 -0
  53. package/src/qingflow_mcp/tools/ai_builder_tools.py +2032 -127
  54. package/src/qingflow_mcp/tools/app_tools.py +419 -12
  55. package/src/qingflow_mcp/tools/approval_tools.py +571 -72
  56. package/src/qingflow_mcp/tools/auth_tools.py +398 -2
  57. package/src/qingflow_mcp/tools/code_block_tools.py +756 -0
  58. package/src/qingflow_mcp/tools/custom_button_tools.py +179 -0
  59. package/src/qingflow_mcp/tools/directory_tools.py +203 -31
  60. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  61. package/src/qingflow_mcp/tools/file_tools.py +1 -0
  62. package/src/qingflow_mcp/tools/import_tools.py +2150 -0
  63. package/src/qingflow_mcp/tools/package_tools.py +18 -4
  64. package/src/qingflow_mcp/tools/portal_tools.py +31 -0
  65. package/src/qingflow_mcp/tools/qingbi_report_tools.py +109 -7
  66. package/src/qingflow_mcp/tools/record_tools.py +9894 -1104
  67. package/src/qingflow_mcp/tools/solution_tools.py +115 -3
  68. package/src/qingflow_mcp/tools/task_context_tools.py +2040 -0
  69. package/src/qingflow_mcp/tools/task_tools.py +376 -225
  70. package/src/qingflow_mcp/tools/workspace_tools.py +163 -19
@@ -18,14 +18,19 @@ class ApprovalTools(ToolBase):
18
18
 
19
19
  def register(self, mcp: FastMCP) -> None:
20
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 {})
21
+ def record_comment_write(
22
+ profile: str = DEFAULT_PROFILE,
23
+ app_key: str = "",
24
+ record_id: int = 0,
25
+ payload: dict[str, Any] | None = None,
26
+ ) -> dict[str, Any]:
27
+ return self.record_comment_write(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {})
23
28
 
24
29
  @mcp.tool()
25
30
  def record_comment_list(
26
31
  profile: str = DEFAULT_PROFILE,
27
32
  app_key: str = "",
28
- apply_id: int = 0,
33
+ record_id: int = 0,
29
34
  page_size: int = 20,
30
35
  list_type: int | None = None,
31
36
  page_num: int | None = 1,
@@ -33,26 +38,26 @@ class ApprovalTools(ToolBase):
33
38
  return self.record_comment_list(
34
39
  profile=profile,
35
40
  app_key=app_key,
36
- apply_id=apply_id,
41
+ apply_id=record_id,
37
42
  page_size=page_size,
38
43
  list_type=list_type,
39
44
  page_num=page_num,
40
45
  )
41
46
 
42
47
  @mcp.tool()
43
- def record_comment_mention_candidates(
48
+ def record_comment_mentions(
44
49
  profile: str = DEFAULT_PROFILE,
45
50
  app_key: str = "",
46
- apply_id: int = 0,
51
+ record_id: int = 0,
47
52
  page_size: int = 20,
48
53
  page_num: int = 1,
49
54
  list_type: int | None = None,
50
55
  keyword: str | None = None,
51
56
  ) -> dict[str, Any]:
52
- return self.record_comment_mention_candidates(
57
+ return self.record_comment_mentions(
53
58
  profile=profile,
54
59
  app_key=app_key,
55
- apply_id=apply_id,
60
+ record_id=record_id,
56
61
  page_size=page_size,
57
62
  page_num=page_num,
58
63
  list_type=list_type,
@@ -60,84 +65,283 @@ class ApprovalTools(ToolBase):
60
65
  )
61
66
 
62
67
  @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)
68
+ def record_comment_mark_read(profile: str = DEFAULT_PROFILE, app_key: str = "", record_id: int = 0) -> dict[str, Any]:
69
+ return self.record_comment_mark_read(profile=profile, app_key=app_key, apply_id=record_id)
65
70
 
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 {})
71
+ @mcp.tool(description=self._high_risk_tool_description(operation="approve", target="workflow task"))
72
+ def task_approve(
73
+ profile: str = DEFAULT_PROFILE,
74
+ app_key: str = "",
75
+ record_id: int = 0,
76
+ payload: dict[str, Any] | None = None,
77
+ fields: dict[str, Any] | None = None,
78
+ ) -> dict[str, Any]:
79
+ return self.task_approve(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {}, fields=fields or {})
73
80
 
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 {})
81
+ @mcp.tool(description=self._high_risk_tool_description(operation="reject", target="workflow task"))
82
+ def task_reject(
83
+ profile: str = DEFAULT_PROFILE,
84
+ app_key: str = "",
85
+ record_id: int = 0,
86
+ payload: dict[str, Any] | None = None,
87
+ fields: dict[str, Any] | None = None,
88
+ ) -> dict[str, Any]:
89
+ return self.task_reject(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {}, fields=fields or {})
77
90
 
78
91
  @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)
92
+ def task_rollback_candidates(
93
+ profile: str = DEFAULT_PROFILE,
94
+ app_key: str = "",
95
+ record_id: int = 0,
96
+ workflow_node_id: int = 0,
97
+ ) -> dict[str, Any]:
98
+ return self.task_rollback_candidates(profile=profile, app_key=app_key, record_id=record_id, workflow_node_id=workflow_node_id)
81
99
 
82
100
  @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 {})
101
+ def task_rollback(
102
+ profile: str = DEFAULT_PROFILE,
103
+ app_key: str = "",
104
+ record_id: int = 0,
105
+ payload: dict[str, Any] | None = None,
106
+ fields: dict[str, Any] | None = None,
107
+ ) -> dict[str, Any]:
108
+ return self.task_rollback(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {}, fields=fields or {})
85
109
 
86
110
  @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 {})
111
+ def task_transfer(
112
+ profile: str = DEFAULT_PROFILE,
113
+ app_key: str = "",
114
+ record_id: int = 0,
115
+ payload: dict[str, Any] | None = None,
116
+ fields: dict[str, Any] | None = None,
117
+ ) -> dict[str, Any]:
118
+ return self.task_transfer(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {}, fields=fields or {})
89
119
 
90
- @mcp.tool()
91
- def record_transfer_candidates(
120
+ @mcp.tool(description=self._high_risk_tool_description(operation="save", target="workflow task fields without advancing the workflow"))
121
+ def task_save_only(
92
122
  profile: str = DEFAULT_PROFILE,
93
123
  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,
124
+ record_id: int = 0,
125
+ workflow_node_id: int = 0,
126
+ fields: dict[str, Any] | None = None,
99
127
  ) -> dict[str, Any]:
100
- return self.record_transfer_candidates(
128
+ return self.task_save_only(
101
129
  profile=profile,
102
130
  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,
131
+ record_id=record_id,
132
+ workflow_node_id=workflow_node_id,
133
+ fields=fields or {},
108
134
  )
109
135
 
110
136
  @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(
137
+ def task_transfer_candidates(
120
138
  profile: str = DEFAULT_PROFILE,
121
139
  app_key: str = "",
122
- apply_id: int = 0,
140
+ record_id: int = 0,
123
141
  page_size: int = 20,
124
142
  page_num: int = 1,
125
- audit_node_id: int = 0,
126
- search_key: str | None = None,
143
+ workflow_node_id: int = 0,
144
+ keyword: str | None = None,
127
145
  ) -> dict[str, Any]:
128
- return self.record_countersign_candidates(
146
+ return self.task_transfer_candidates(
129
147
  profile=profile,
130
148
  app_key=app_key,
131
- apply_id=apply_id,
149
+ record_id=record_id,
132
150
  page_size=page_size,
133
151
  page_num=page_num,
134
- audit_node_id=audit_node_id,
135
- search_key=search_key,
152
+ workflow_node_id=workflow_node_id,
153
+ keyword=keyword,
136
154
  )
137
155
 
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 {})
156
+ def record_comment_write(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any]) -> dict[str, Any]:
157
+ raw = self.record_comment_add(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
158
+ return self._public_action_response(
159
+ raw,
160
+ action="record_comment_write",
161
+ resource={"app_key": app_key, "record_id": record_id},
162
+ selection={},
163
+ )
164
+
165
+ def record_comment_mentions(
166
+ self,
167
+ *,
168
+ profile: str,
169
+ app_key: str,
170
+ record_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
+ raw = self.record_comment_mention_candidates(
177
+ profile=profile,
178
+ app_key=app_key,
179
+ apply_id=record_id,
180
+ page_size=page_size,
181
+ page_num=page_num,
182
+ list_type=list_type,
183
+ keyword=keyword,
184
+ )
185
+ items = _approval_page_items(raw.get("page"))
186
+ return self._public_page_response(
187
+ raw,
188
+ items=items,
189
+ pagination={
190
+ "page": page_num,
191
+ "page_size": page_size,
192
+ "returned_items": len(items),
193
+ "page_amount": _approval_page_amount(raw.get("page")),
194
+ "reported_total": _approval_page_total(raw.get("page")),
195
+ },
196
+ selection={"app_key": app_key, "record_id": record_id, "list_type": list_type, "keyword": keyword},
197
+ )
198
+
199
+ def task_approve(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any], fields: dict[str, Any] | None = None) -> dict[str, Any]:
200
+ if fields:
201
+ return self._delegate_task_action(
202
+ profile=profile,
203
+ app_key=app_key,
204
+ record_id=record_id,
205
+ action="approve",
206
+ payload=payload,
207
+ fields=fields,
208
+ public_action="task_approve",
209
+ )
210
+ raw = self.record_approve(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
211
+ return self._public_action_response(
212
+ raw,
213
+ action="task_approve",
214
+ resource={"app_key": app_key, "record_id": record_id},
215
+ selection={},
216
+ human_review=True,
217
+ )
218
+
219
+ def task_reject(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any], fields: dict[str, Any] | None = None) -> dict[str, Any]:
220
+ if fields:
221
+ return self._delegate_task_action(
222
+ profile=profile,
223
+ app_key=app_key,
224
+ record_id=record_id,
225
+ action="reject",
226
+ payload=payload,
227
+ fields=fields,
228
+ public_action="task_reject",
229
+ )
230
+ raw = self.record_reject(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
231
+ return self._public_action_response(
232
+ raw,
233
+ action="task_reject",
234
+ resource={"app_key": app_key, "record_id": record_id},
235
+ selection={},
236
+ human_review=True,
237
+ )
238
+
239
+ def task_rollback_candidates(self, *, profile: str, app_key: str, record_id: int, workflow_node_id: int) -> dict[str, Any]:
240
+ raw = self.record_rollback_candidates(profile=profile, app_key=app_key, apply_id=record_id, audit_node_id=workflow_node_id)
241
+ items = _approval_page_items(raw.get("result"))
242
+ return self._public_page_response(
243
+ raw,
244
+ items=items,
245
+ pagination={"returned_items": len(items)},
246
+ selection={"app_key": app_key, "record_id": record_id, "workflow_node_id": workflow_node_id},
247
+ )
248
+
249
+ def task_rollback(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any], fields: dict[str, Any] | None = None) -> dict[str, Any]:
250
+ if fields:
251
+ return self._delegate_task_action(
252
+ profile=profile,
253
+ app_key=app_key,
254
+ record_id=record_id,
255
+ action="rollback",
256
+ payload=payload,
257
+ fields=fields,
258
+ public_action="task_rollback",
259
+ )
260
+ raw = self.record_rollback(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
261
+ return self._public_action_response(
262
+ raw,
263
+ action="task_rollback",
264
+ resource={"app_key": app_key, "record_id": record_id},
265
+ selection={},
266
+ human_review=True,
267
+ )
268
+
269
+ def task_transfer(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any], fields: dict[str, Any] | None = None) -> dict[str, Any]:
270
+ if fields:
271
+ return self._delegate_task_action(
272
+ profile=profile,
273
+ app_key=app_key,
274
+ record_id=record_id,
275
+ action="transfer",
276
+ payload=payload,
277
+ fields=fields,
278
+ public_action="task_transfer",
279
+ )
280
+ self._raise_if_self_transfer(profile=profile, payload=payload)
281
+ raw = self.record_transfer(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
282
+ return self._public_action_response(
283
+ raw,
284
+ action="task_transfer",
285
+ resource={"app_key": app_key, "record_id": record_id},
286
+ selection={},
287
+ human_review=True,
288
+ )
289
+
290
+ def task_save_only(
291
+ self,
292
+ *,
293
+ profile: str,
294
+ app_key: str,
295
+ record_id: int,
296
+ workflow_node_id: int,
297
+ fields: dict[str, Any],
298
+ ) -> dict[str, Any]:
299
+ return self._delegate_task_action(
300
+ profile=profile,
301
+ app_key=app_key,
302
+ record_id=record_id,
303
+ action="save_only",
304
+ payload={},
305
+ fields=fields,
306
+ public_action="task_save_only",
307
+ workflow_node_id=workflow_node_id,
308
+ )
309
+
310
+ def task_transfer_candidates(
311
+ self,
312
+ *,
313
+ profile: str,
314
+ app_key: str,
315
+ record_id: int,
316
+ page_size: int = 20,
317
+ page_num: int = 1,
318
+ workflow_node_id: int = 0,
319
+ keyword: str | None = None,
320
+ ) -> dict[str, Any]:
321
+ raw = self.record_transfer_candidates(
322
+ profile=profile,
323
+ app_key=app_key,
324
+ apply_id=record_id,
325
+ page_size=page_size,
326
+ page_num=page_num,
327
+ audit_node_id=workflow_node_id,
328
+ keyword=keyword,
329
+ )
330
+ original_items = _approval_page_items(raw.get("page"))
331
+ items = self._filter_self_transfer_candidates(profile=profile, items=original_items)
332
+ filtered_count = max(len(original_items) - len(items), 0)
333
+ return self._public_page_response(
334
+ raw,
335
+ items=items,
336
+ pagination={
337
+ "page": page_num,
338
+ "page_size": page_size,
339
+ "returned_items": len(items),
340
+ "page_amount": _approval_page_amount(raw.get("page")),
341
+ "reported_total": max(_approval_page_total(raw.get("page")) - filtered_count, 0),
342
+ },
343
+ selection={"app_key": app_key, "record_id": record_id, "workflow_node_id": workflow_node_id, "keyword": keyword},
344
+ )
141
345
 
142
346
  def record_comment_add(self, *, profile: str, app_key: str, apply_id: int, payload: dict[str, Any]) -> dict[str, Any]:
143
347
  self._require_app_and_apply(app_key, apply_id)
@@ -145,7 +349,14 @@ class ApprovalTools(ToolBase):
145
349
 
146
350
  def runner(session_profile, context):
147
351
  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}
352
+ return {
353
+ "profile": profile,
354
+ "ws_id": session_profile.selected_ws_id,
355
+ "app_key": app_key,
356
+ "apply_id": apply_id,
357
+ "result": result,
358
+ "request_route": self._request_route_payload(context),
359
+ }
149
360
 
150
361
  return self._run(profile, runner)
151
362
 
@@ -167,9 +378,23 @@ class ApprovalTools(ToolBase):
167
378
  "list_type": list_type,
168
379
  "list_type_label": get_record_list_type_label(list_type),
169
380
  "page": result,
381
+ "request_route": self._request_route_payload(context),
170
382
  }
171
383
 
172
- return self._run(profile, runner)
384
+ raw = self._run(profile, runner)
385
+ items = _approval_page_items(raw.get("page"))
386
+ return self._public_page_response(
387
+ raw,
388
+ items=items,
389
+ pagination={
390
+ "page": page_num,
391
+ "page_size": page_size,
392
+ "returned_items": len(items),
393
+ "page_amount": _approval_page_amount(raw.get("page")),
394
+ "reported_total": _approval_page_total(raw.get("page")),
395
+ },
396
+ selection={"app_key": app_key, "record_id": apply_id, "list_type": list_type},
397
+ )
173
398
 
174
399
  def record_comment_mention_candidates(
175
400
  self,
@@ -199,6 +424,7 @@ class ApprovalTools(ToolBase):
199
424
  "list_type": list_type,
200
425
  "list_type_label": get_record_list_type_label(list_type),
201
426
  "page": result,
427
+ "request_route": self._request_route_payload(context),
202
428
  }
203
429
 
204
430
  return self._run(profile, runner)
@@ -208,16 +434,36 @@ class ApprovalTools(ToolBase):
208
434
 
209
435
  def runner(session_profile, context):
210
436
  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}
437
+ return {
438
+ "profile": profile,
439
+ "ws_id": session_profile.selected_ws_id,
440
+ "app_key": app_key,
441
+ "apply_id": apply_id,
442
+ "result": result,
443
+ "request_route": self._request_route_payload(context),
444
+ }
212
445
 
213
- return self._run(profile, runner)
446
+ raw = self._run(profile, runner)
447
+ return self._public_action_response(
448
+ raw,
449
+ action="record_comment_mark_read",
450
+ resource={"app_key": app_key, "record_id": apply_id},
451
+ selection={},
452
+ )
214
453
 
215
454
  def record_comment_stats(self, *, profile: str, app_key: str, apply_id: int) -> dict[str, Any]:
216
455
  self._require_app_and_apply(app_key, apply_id)
217
456
 
218
457
  def runner(session_profile, context):
219
458
  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}
459
+ return {
460
+ "profile": profile,
461
+ "ws_id": session_profile.selected_ws_id,
462
+ "app_key": app_key,
463
+ "apply_id": apply_id,
464
+ "result": result,
465
+ "request_route": self._request_route_payload(context),
466
+ }
221
467
 
222
468
  return self._run(profile, runner)
223
469
 
@@ -273,7 +519,14 @@ class ApprovalTools(ToolBase):
273
519
  f"/app/{app_key}/apply/{apply_id}/revertNode",
274
520
  params={"auditNodeId": audit_node_id},
275
521
  )
276
- return {"profile": profile, "ws_id": session_profile.selected_ws_id, "app_key": app_key, "apply_id": apply_id, "result": result}
522
+ return {
523
+ "profile": profile,
524
+ "ws_id": session_profile.selected_ws_id,
525
+ "app_key": app_key,
526
+ "apply_id": apply_id,
527
+ "result": result,
528
+ "request_route": self._request_route_payload(context),
529
+ }
277
530
 
278
531
  return self._run(profile, runner)
279
532
 
@@ -284,7 +537,14 @@ class ApprovalTools(ToolBase):
284
537
 
285
538
  def runner(session_profile, context):
286
539
  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}
540
+ return {
541
+ "profile": profile,
542
+ "ws_id": session_profile.selected_ws_id,
543
+ "app_key": app_key,
544
+ "apply_id": apply_id,
545
+ "result": result,
546
+ "request_route": self._request_route_payload(context),
547
+ }
288
548
 
289
549
  return self._run(profile, runner)
290
550
 
@@ -295,7 +555,14 @@ class ApprovalTools(ToolBase):
295
555
 
296
556
  def runner(session_profile, context):
297
557
  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}
558
+ return {
559
+ "profile": profile,
560
+ "ws_id": session_profile.selected_ws_id,
561
+ "app_key": app_key,
562
+ "apply_id": apply_id,
563
+ "result": result,
564
+ "request_route": self._request_route_payload(context),
565
+ }
299
566
 
300
567
  return self._run(profile, runner)
301
568
 
@@ -319,7 +586,14 @@ class ApprovalTools(ToolBase):
319
586
  if keyword:
320
587
  params["keyword"] = keyword
321
588
  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}
589
+ return {
590
+ "profile": profile,
591
+ "ws_id": session_profile.selected_ws_id,
592
+ "app_key": app_key,
593
+ "apply_id": apply_id,
594
+ "page": result,
595
+ "request_route": self._request_route_payload(context),
596
+ }
323
597
 
324
598
  return self._run(profile, runner)
325
599
 
@@ -328,7 +602,14 @@ class ApprovalTools(ToolBase):
328
602
 
329
603
  def runner(session_profile, context):
330
604
  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}
605
+ return {
606
+ "profile": profile,
607
+ "ws_id": session_profile.selected_ws_id,
608
+ "app_key": app_key,
609
+ "apply_id": apply_id,
610
+ "result": result,
611
+ "request_route": self._request_route_payload(context),
612
+ }
332
613
 
333
614
  return self._run(profile, runner)
334
615
 
@@ -338,7 +619,14 @@ class ApprovalTools(ToolBase):
338
619
 
339
620
  def runner(session_profile, context):
340
621
  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}
622
+ return {
623
+ "profile": profile,
624
+ "ws_id": session_profile.selected_ws_id,
625
+ "app_key": app_key,
626
+ "apply_id": apply_id,
627
+ "result": result,
628
+ "request_route": self._request_route_payload(context),
629
+ }
342
630
 
343
631
  return self._run(profile, runner)
344
632
 
@@ -362,7 +650,14 @@ class ApprovalTools(ToolBase):
362
650
  if search_key:
363
651
  params["searchKey"] = search_key
364
652
  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}
653
+ return {
654
+ "profile": profile,
655
+ "ws_id": session_profile.selected_ws_id,
656
+ "app_key": app_key,
657
+ "apply_id": apply_id,
658
+ "page": result,
659
+ "request_route": self._request_route_payload(context),
660
+ }
366
661
 
367
662
  return self._run(profile, runner)
368
663
 
@@ -373,10 +668,29 @@ class ApprovalTools(ToolBase):
373
668
 
374
669
  def runner(session_profile, context):
375
670
  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}
671
+ return {
672
+ "profile": profile,
673
+ "ws_id": session_profile.selected_ws_id,
674
+ "app_key": app_key,
675
+ "apply_id": apply_id,
676
+ "result": result,
677
+ "request_route": self._request_route_payload(context),
678
+ }
377
679
 
378
680
  return self._run(profile, runner)
379
681
 
682
+ def _request_route_payload(self, context) -> JSONObject: # type: ignore[no-untyped-def]
683
+ describe_route = getattr(self.backend, "describe_route", None)
684
+ if callable(describe_route):
685
+ payload = describe_route(context)
686
+ if isinstance(payload, dict):
687
+ return payload
688
+ return {
689
+ "base_url": getattr(context, "base_url", None),
690
+ "qf_version": getattr(context, "qf_version", None),
691
+ "qf_version_source": getattr(context, "qf_version_source", None) or ("context" if getattr(context, "qf_version", None) else "unknown"),
692
+ }
693
+
380
694
  def _require_app_and_apply(self, app_key: str, apply_id: int) -> None:
381
695
  if not app_key:
382
696
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
@@ -400,9 +714,11 @@ class ApprovalTools(ToolBase):
400
714
  self._normalize_alias(body, "formId", "form_id")
401
715
 
402
716
  node_id = self._extract_node_id(body)
403
- body["nodeId"] = node_id
717
+ body["nodeId"] = self._resolve_actionable_node_id(context, app_key, apply_id, node_id)
404
718
  body["applyId"] = self._match_or_fill_int(body, field_name="applyId", expected_value=apply_id)
405
719
  body["formId"] = self._resolve_form_id(profile, context, app_key, explicit_form_id=body.get("formId"))
720
+ if body.get("answers") is None:
721
+ body["answers"] = self._fetch_current_todo_answers(context, app_key, apply_id, body["nodeId"])
406
722
 
407
723
  self._validate_approval_payload(body)
408
724
  return body
@@ -461,6 +777,54 @@ class ApprovalTools(ToolBase):
461
777
  elif alias_value is not None and payload.get(canonical_key) != alias_value:
462
778
  raise_tool_error(QingflowApiError.config_error(f"payload.{canonical_key} and payload.{alias_key} must match when both are provided"))
463
779
 
780
+ def _resolve_actionable_node_id(self, context, app_key: str, apply_id: int, node_id: int) -> int: # type: ignore[no-untyped-def]
781
+ infos = self.backend.request(
782
+ "GET",
783
+ context,
784
+ f"/app/{app_key}/apply/{apply_id}/auditInfo",
785
+ params={"type": 1},
786
+ )
787
+ if not isinstance(infos, list) or not infos:
788
+ raise_tool_error(
789
+ QingflowApiError.config_error(
790
+ f"apply_id={apply_id} is not currently actionable for the logged-in user in todo list"
791
+ )
792
+ )
793
+ actionable_node_ids = {
794
+ candidate
795
+ for item in infos
796
+ if isinstance(item, dict)
797
+ for candidate in (item.get("auditNodeId"), item.get("nodeId"))
798
+ if isinstance(candidate, int) and candidate > 0
799
+ }
800
+ if node_id not in actionable_node_ids:
801
+ raise_tool_error(
802
+ QingflowApiError.config_error(
803
+ f"payload.nodeId={node_id} is not an actionable todo node for apply_id={apply_id}"
804
+ )
805
+ )
806
+ return node_id
807
+
808
+ def _fetch_current_todo_answers(self, context, app_key: str, apply_id: int, node_id: int) -> list[dict[str, Any]]: # type: ignore[no-untyped-def]
809
+ detail = self.backend.request(
810
+ "GET",
811
+ context,
812
+ f"/app/{app_key}/apply/{apply_id}",
813
+ params={"role": 3, "listType": 1, "auditNodeId": node_id},
814
+ )
815
+ answers = detail.get("answers") if isinstance(detail, dict) else None
816
+ if not isinstance(answers, list):
817
+ raise_tool_error(
818
+ QingflowApiError.config_error(
819
+ f"cannot resolve current answers for apply_id={apply_id} nodeId={node_id}"
820
+ )
821
+ )
822
+ normalized_answers: list[dict[str, Any]] = []
823
+ for item in answers:
824
+ if isinstance(item, dict):
825
+ normalized_answers.append(dict(item))
826
+ return normalized_answers
827
+
464
828
  def _validate_approval_payload(self, payload: dict[str, Any]) -> None:
465
829
  self._reject_unsupported_fields(payload)
466
830
  if not isinstance(payload.get("formId"), int) or payload["formId"] <= 0:
@@ -469,6 +833,9 @@ class ApprovalTools(ToolBase):
469
833
  raise_tool_error(QingflowApiError.config_error("payload.applyId must be a positive integer"))
470
834
  if not isinstance(payload.get("nodeId"), int) or payload["nodeId"] <= 0:
471
835
  raise_tool_error(QingflowApiError.config_error("payload.nodeId must be a positive integer"))
836
+ answers = payload.get("answers")
837
+ if answers is not None and not isinstance(answers, list):
838
+ raise_tool_error(QingflowApiError.config_error("payload.answers must be an array when provided"))
472
839
 
473
840
  def _validate_audit_payload(self, payload: dict[str, Any], *, require_uid: bool = False) -> None:
474
841
  self._reject_unsupported_fields(payload)
@@ -485,6 +852,108 @@ class ApprovalTools(ToolBase):
485
852
  if payload.get("handSignImageUrl"):
486
853
  raise_tool_error(QingflowApiError.not_supported("NOT_SUPPORTED_IN_V1: handSignImageUrl is not supported"))
487
854
 
855
+ def _extract_transfer_target_uid(self, payload: dict[str, Any]) -> int | None:
856
+ for key in ("uid", "target_member_id", "targetMemberId"):
857
+ value = payload.get(key)
858
+ if isinstance(value, int) and value > 0:
859
+ return value
860
+ return None
861
+
862
+ def _raise_if_self_transfer(self, *, profile: str, payload: dict[str, Any]) -> None:
863
+ target_uid = self._extract_transfer_target_uid(payload)
864
+ if target_uid is None:
865
+ return
866
+ session_profile = self.sessions.get_profile(profile)
867
+ if session_profile is not None and target_uid == session_profile.uid:
868
+ raise_tool_error(
869
+ QingflowApiError.config_error(
870
+ "task transfer does not support transferring to the current user; choose another transfer member"
871
+ )
872
+ )
873
+
874
+ def _filter_self_transfer_candidates(self, *, profile: str, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
875
+ session_profile = self.sessions.get_profile(profile)
876
+ if session_profile is None:
877
+ return items
878
+ current_uid = session_profile.uid
879
+ return [item for item in items if item.get("uid") != current_uid]
880
+
881
+ def _delegate_task_action(
882
+ self,
883
+ *,
884
+ profile: str,
885
+ app_key: str,
886
+ record_id: int,
887
+ action: str,
888
+ payload: dict[str, Any],
889
+ fields: dict[str, Any],
890
+ public_action: str,
891
+ workflow_node_id: int | None = None,
892
+ ) -> dict[str, Any]:
893
+ from .task_context_tools import TaskContextTools
894
+
895
+ node_id = workflow_node_id
896
+ if node_id is None:
897
+ node_payload = dict(payload or {})
898
+ node_id = self._extract_node_id(node_payload)
899
+ delegated = TaskContextTools(self.sessions, self.backend).task_action_execute(
900
+ profile=profile,
901
+ app_key=app_key,
902
+ record_id=record_id,
903
+ workflow_node_id=node_id,
904
+ action=action,
905
+ payload=payload or {},
906
+ fields=fields or {},
907
+ )
908
+ response = dict(delegated)
909
+ data = response.get("data")
910
+ if isinstance(data, dict):
911
+ payload_data = dict(data)
912
+ payload_data["action"] = public_action
913
+ response["data"] = payload_data
914
+ return response
915
+
916
+ def _public_page_response(
917
+ self,
918
+ raw: dict[str, Any],
919
+ *,
920
+ items: list[dict[str, Any]],
921
+ pagination: dict[str, Any],
922
+ selection: dict[str, Any],
923
+ ) -> dict[str, Any]:
924
+ response = dict(raw)
925
+ response["ok"] = bool(raw.get("ok", True))
926
+ response["warnings"] = []
927
+ response["output_profile"] = "normal"
928
+ response["data"] = {
929
+ "items": items,
930
+ "pagination": pagination,
931
+ "selection": selection,
932
+ }
933
+ return response
934
+
935
+ def _public_action_response(
936
+ self,
937
+ raw: dict[str, Any],
938
+ *,
939
+ action: str,
940
+ resource: dict[str, Any],
941
+ selection: dict[str, Any],
942
+ human_review: bool = False,
943
+ ) -> dict[str, Any]:
944
+ response = dict(raw)
945
+ response["ok"] = bool(raw.get("ok", True))
946
+ response["warnings"] = []
947
+ response["output_profile"] = "normal"
948
+ response["data"] = {
949
+ "action": action,
950
+ "resource": resource,
951
+ "selection": selection,
952
+ "result": raw.get("result"),
953
+ "human_review": human_review,
954
+ }
955
+ return response
956
+
488
957
  def _request_route_payload(self, context) -> JSONObject: # type: ignore[no-untyped-def]
489
958
  describe_route = getattr(self.backend, "describe_route", None)
490
959
  if callable(describe_route):
@@ -496,3 +965,33 @@ class ApprovalTools(ToolBase):
496
965
  "qf_version": context.qf_version,
497
966
  "qf_version_source": getattr(context, "qf_version_source", None) or ("context" if getattr(context, "qf_version", None) else "unknown"),
498
967
  }
968
+
969
+
970
+ def _approval_page_items(payload: Any) -> list[dict[str, Any]]:
971
+ if isinstance(payload, list):
972
+ return [item for item in payload if isinstance(item, dict)]
973
+ if not isinstance(payload, dict):
974
+ return []
975
+ for key in ("list", "items", "rows", "result"):
976
+ value = payload.get(key)
977
+ if isinstance(value, list):
978
+ return [item for item in value if isinstance(item, dict)]
979
+ for container_key in ("data", "page"):
980
+ nested = payload.get(container_key)
981
+ if isinstance(nested, dict):
982
+ nested_items = _approval_page_items(nested)
983
+ if nested_items:
984
+ return nested_items
985
+ return []
986
+
987
+
988
+ def _approval_page_amount(payload: Any) -> Any:
989
+ if isinstance(payload, dict):
990
+ return payload.get("pageAmount", payload.get("page_amount"))
991
+ return None
992
+
993
+
994
+ def _approval_page_total(payload: Any) -> Any:
995
+ if isinstance(payload, dict):
996
+ return payload.get("total", payload.get("count"))
997
+ return None