@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,375 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import mimetypes
6
+ import random
7
+ import string
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.parse import quote
11
+
12
+ from mcp.server.fastmcp import FastMCP
13
+
14
+ from ..config import DEFAULT_PROFILE
15
+ from ..errors import QingflowApiError, raise_tool_error
16
+ from ..json_types import JSONObject
17
+ from .base import ToolBase
18
+
19
+
20
+ ATTACHMENT_UPLOAD_INFO_FALLBACK_CODES = {"40118", 40118}
21
+ LEGACY_OSS_FORM_REQUIRED_KEYS = ("key", "policy", "signature", "ossAccessKeyId")
22
+
23
+
24
+ class FileTools(ToolBase):
25
+ def register(self, mcp: FastMCP) -> None:
26
+ @mcp.tool()
27
+ def file_get_upload_info(
28
+ profile: str = DEFAULT_PROFILE,
29
+ upload_kind: str = "attachment",
30
+ file_name: str = "",
31
+ file_size: int = 0,
32
+ upload_mark: str | None = None,
33
+ content_type: str | None = None,
34
+ bucket_type: str | None = None,
35
+ path_id: int | None = None,
36
+ file_related_url: str | None = None,
37
+ ) -> dict[str, Any]:
38
+ return self.file_get_upload_info(
39
+ profile=profile,
40
+ upload_kind=upload_kind,
41
+ file_name=file_name,
42
+ file_size=file_size,
43
+ upload_mark=upload_mark,
44
+ content_type=content_type,
45
+ bucket_type=bucket_type,
46
+ path_id=path_id,
47
+ file_related_url=file_related_url,
48
+ )
49
+
50
+ @mcp.tool()
51
+ def file_upload_local(
52
+ profile: str = DEFAULT_PROFILE,
53
+ upload_kind: str = "attachment",
54
+ file_path: str = "",
55
+ upload_mark: str | None = None,
56
+ content_type: str | None = None,
57
+ bucket_type: str | None = None,
58
+ path_id: int | None = None,
59
+ file_related_url: str | None = None,
60
+ ) -> dict[str, Any]:
61
+ return self.file_upload_local(
62
+ profile=profile,
63
+ upload_kind=upload_kind,
64
+ file_path=file_path,
65
+ upload_mark=upload_mark,
66
+ content_type=content_type,
67
+ bucket_type=bucket_type,
68
+ path_id=path_id,
69
+ file_related_url=file_related_url,
70
+ )
71
+
72
+ def file_get_upload_info(
73
+ self,
74
+ *,
75
+ profile: str,
76
+ upload_kind: str,
77
+ file_name: str,
78
+ file_size: int,
79
+ upload_mark: str | None = None,
80
+ content_type: str | None = None,
81
+ bucket_type: str | None = None,
82
+ path_id: int | None = None,
83
+ file_related_url: str | None = None,
84
+ ) -> dict[str, Any]:
85
+ def runner(session_profile, context):
86
+ upload_info = self._request_upload_info_with_fallback(
87
+ context,
88
+ upload_kind=upload_kind,
89
+ file_name=file_name,
90
+ file_size=file_size,
91
+ upload_mark=upload_mark,
92
+ content_type=content_type,
93
+ bucket_type=bucket_type,
94
+ path_id=path_id,
95
+ file_related_url=file_related_url,
96
+ )
97
+ return {
98
+ "profile": profile,
99
+ "ws_id": session_profile.selected_ws_id,
100
+ "upload_kind": upload_kind,
101
+ "effective_upload_kind": upload_info["effective_upload_kind"],
102
+ "upload_fallback_applied": upload_info["fallback_applied"],
103
+ "file_name": file_name,
104
+ "file_size": file_size,
105
+ "request_route": self.backend.describe_route(context),
106
+ "result": upload_info["result"],
107
+ }
108
+
109
+ return self._run(profile, runner)
110
+
111
+ def file_upload_local(
112
+ self,
113
+ *,
114
+ profile: str,
115
+ upload_kind: str,
116
+ file_path: str,
117
+ upload_mark: str | None = None,
118
+ content_type: str | None = None,
119
+ bucket_type: str | None = None,
120
+ path_id: int | None = None,
121
+ file_related_url: str | None = None,
122
+ ) -> dict[str, Any]:
123
+ path = Path(file_path).expanduser()
124
+ if not path.is_file():
125
+ raise_tool_error(QingflowApiError.config_error("file_path must point to an existing file"))
126
+ file_name = path.name
127
+ file_size = path.stat().st_size
128
+ resolved_content_type = content_type or mimetypes.guess_type(file_name)[0] or "application/octet-stream"
129
+
130
+ def runner(session_profile, context):
131
+ upload_info = self._request_upload_info_with_fallback(
132
+ context,
133
+ upload_kind=upload_kind,
134
+ file_name=file_name,
135
+ file_size=file_size,
136
+ upload_mark=upload_mark,
137
+ content_type=resolved_content_type,
138
+ bucket_type=bucket_type,
139
+ path_id=path_id,
140
+ file_related_url=file_related_url,
141
+ )
142
+ result = upload_info["result"]
143
+ if not isinstance(result, dict):
144
+ raise QingflowApiError.config_error("upload endpoint did not return a structured upload payload")
145
+ content = path.read_bytes()
146
+ upload_protocol = "binary_put"
147
+ if self._is_legacy_oss_form_upload(result):
148
+ upload_result = self._upload_legacy_oss_form(
149
+ result,
150
+ file_name=file_name,
151
+ content=content,
152
+ content_type=resolved_content_type,
153
+ )
154
+ upload_protocol = "oss_form_post"
155
+ else:
156
+ upload_url = str(result.get("uploadUrl") or "").strip()
157
+ if not upload_url:
158
+ raise QingflowApiError.config_error("upload endpoint did not return uploadUrl")
159
+ upload_result = self.backend.upload_binary(upload_url, content, content_type=resolved_content_type)
160
+ download_url = result.get("downloadUrl")
161
+ return {
162
+ "profile": profile,
163
+ "ws_id": session_profile.selected_ws_id,
164
+ "upload_kind": upload_kind,
165
+ "effective_upload_kind": upload_info["effective_upload_kind"],
166
+ "upload_fallback_applied": upload_info["fallback_applied"],
167
+ "file_name": file_name,
168
+ "file_size": file_size,
169
+ "content_type": resolved_content_type,
170
+ "request_route": self.backend.describe_route(context),
171
+ "upload_protocol": upload_protocol,
172
+ "result": result,
173
+ "upload_result": upload_result,
174
+ "download_url": download_url,
175
+ "attachment_value": {
176
+ "value": download_url,
177
+ "name": file_name,
178
+ },
179
+ "comment_file_info": {
180
+ "url": download_url,
181
+ "name": file_name,
182
+ "uploadFileSize": file_size,
183
+ },
184
+ }
185
+
186
+ return self._run(profile, runner)
187
+
188
+ def _build_file_upload_bo(
189
+ self,
190
+ *,
191
+ upload_kind: str,
192
+ file_name: str,
193
+ file_size: int,
194
+ upload_mark: str | None,
195
+ content_type: str | None,
196
+ bucket_type: str | None,
197
+ path_id: int | None,
198
+ file_related_url: str | None,
199
+ ) -> dict[str, Any]:
200
+ if not file_name:
201
+ raise_tool_error(QingflowApiError.config_error("file_name is required"))
202
+ if file_size <= 0:
203
+ raise_tool_error(QingflowApiError.config_error("file_size must be positive"))
204
+ if upload_kind == "attachment" and not upload_mark:
205
+ raise_tool_error(QingflowApiError.config_error("upload_mark is required for attachment uploads and should usually be the app_key"))
206
+ payload: dict[str, Any] = {
207
+ "fileName": file_name,
208
+ "fileSize": file_size,
209
+ }
210
+ if upload_mark:
211
+ payload["uploadMark"] = upload_mark
212
+ if content_type:
213
+ payload["contentType"] = content_type
214
+ if bucket_type:
215
+ payload["bucketType"] = bucket_type
216
+ if path_id is not None:
217
+ payload["pathId"] = path_id
218
+ if file_related_url:
219
+ payload["fileRelatedUrl"] = file_related_url
220
+ return payload
221
+
222
+ def _resolve_upload_endpoint(self, upload_kind: str) -> str:
223
+ normalized = upload_kind.strip().lower()
224
+ if normalized == "attachment":
225
+ return "/upload/puburl"
226
+ if normalized == "login":
227
+ return "/upload/url"
228
+ if normalized == "anonymous":
229
+ return "/upload/anonymousurl"
230
+ raise_tool_error(QingflowApiError.config_error("upload_kind must be one of: attachment, login, anonymous"))
231
+ raise AssertionError("unreachable")
232
+
233
+ def _encode_formula(self, formula: str) -> str:
234
+ encoded = base64.b64encode(quote(formula, safe="").encode("utf-8")).decode("utf-8")
235
+ return f"{self._random_string(16)}{encoded}{self._random_string(16)}"
236
+
237
+ def _random_string(self, length: int) -> str:
238
+ alphabet = string.ascii_letters + string.digits
239
+ return "".join(random.choice(alphabet) for _ in range(length))
240
+
241
+ def _request_upload_info_with_fallback(
242
+ self,
243
+ context,
244
+ *,
245
+ upload_kind: str,
246
+ file_name: str,
247
+ file_size: int,
248
+ upload_mark: str | None,
249
+ content_type: str | None,
250
+ bucket_type: str | None,
251
+ path_id: int | None,
252
+ file_related_url: str | None,
253
+ ) -> dict[str, Any]:
254
+ requested_kind = upload_kind.strip().lower()
255
+ attempted_kinds = [requested_kind]
256
+ if requested_kind == "attachment":
257
+ attempted_kinds.append("login")
258
+ last_error: QingflowApiError | None = None
259
+ for current_kind in attempted_kinds:
260
+ try:
261
+ result = self._request_upload_info(
262
+ context,
263
+ upload_kind=current_kind,
264
+ file_name=file_name,
265
+ file_size=file_size,
266
+ upload_mark=upload_mark,
267
+ content_type=content_type,
268
+ bucket_type=bucket_type,
269
+ path_id=path_id,
270
+ file_related_url=file_related_url,
271
+ )
272
+ return {
273
+ "requested_upload_kind": requested_kind,
274
+ "effective_upload_kind": current_kind,
275
+ "fallback_applied": current_kind != requested_kind,
276
+ "result": result,
277
+ }
278
+ except QingflowApiError as error:
279
+ last_error = error
280
+ if not self._should_retry_attachment_upload_info(requested_kind, current_kind, error):
281
+ raise
282
+ assert last_error is not None
283
+ raise last_error
284
+
285
+ def _request_upload_info(
286
+ self,
287
+ context,
288
+ *,
289
+ upload_kind: str,
290
+ file_name: str,
291
+ file_size: int,
292
+ upload_mark: str | None,
293
+ content_type: str | None,
294
+ bucket_type: str | None,
295
+ path_id: int | None,
296
+ file_related_url: str | None,
297
+ ) -> JSONObject:
298
+ endpoint = self._resolve_upload_endpoint(upload_kind)
299
+ file_upload_bo = self._build_file_upload_bo(
300
+ upload_kind=upload_kind,
301
+ file_name=file_name,
302
+ file_size=file_size,
303
+ upload_mark=upload_mark,
304
+ content_type=content_type,
305
+ bucket_type=bucket_type,
306
+ path_id=path_id,
307
+ file_related_url=file_related_url,
308
+ )
309
+ encrypted_payload = {
310
+ "upload": self._encode_formula(json.dumps(file_upload_bo, ensure_ascii=False, separators=(",", ":"))),
311
+ "fileName": file_name,
312
+ "fileSize": file_size,
313
+ }
314
+ if path_id is not None:
315
+ encrypted_payload["pathId"] = path_id
316
+ if bucket_type:
317
+ encrypted_payload["bucketType"] = bucket_type
318
+ result = self.backend.request("POST", context, endpoint, json_body=encrypted_payload)
319
+ if not isinstance(result, dict):
320
+ raise QingflowApiError.config_error("upload endpoint did not return a structured upload payload")
321
+ return result
322
+
323
+ def _should_retry_attachment_upload_info(
324
+ self,
325
+ requested_kind: str,
326
+ attempted_kind: str,
327
+ error: QingflowApiError,
328
+ ) -> bool:
329
+ return (
330
+ requested_kind == "attachment"
331
+ and attempted_kind == "attachment"
332
+ and error.backend_code in ATTACHMENT_UPLOAD_INFO_FALLBACK_CODES
333
+ )
334
+
335
+ def _is_legacy_oss_form_upload(self, payload: JSONObject) -> bool:
336
+ return all(str(payload.get(key) or "").strip() for key in LEGACY_OSS_FORM_REQUIRED_KEYS)
337
+
338
+ def _upload_legacy_oss_form(
339
+ self,
340
+ upload_info: JSONObject,
341
+ *,
342
+ file_name: str,
343
+ content: bytes,
344
+ content_type: str,
345
+ ) -> dict[str, Any]:
346
+ upload_url = self._resolve_legacy_oss_form_url(upload_info)
347
+ if not upload_url:
348
+ raise QingflowApiError.config_error("legacy upload payload is missing host/uploadAccelerateHost")
349
+ form_fields = {
350
+ "key": str(upload_info["key"]),
351
+ "policy": str(upload_info["policy"]),
352
+ "signature": str(upload_info["signature"]),
353
+ "OSSAccessKeyId": str(upload_info["ossAccessKeyId"]),
354
+ }
355
+ callback = str(upload_info.get("callback") or "").strip()
356
+ if callback:
357
+ form_fields["callback"] = callback
358
+ return self.backend.upload_form_file(
359
+ upload_url,
360
+ form_fields=form_fields,
361
+ file_field="file",
362
+ file_name=file_name,
363
+ content=content,
364
+ content_type=content_type,
365
+ )
366
+
367
+ def _resolve_legacy_oss_form_url(self, upload_info: JSONObject) -> str:
368
+ for key in ("uploadAccelerateHost", "host", "uploadUrl"):
369
+ value = str(upload_info.get(key) or "").strip()
370
+ if not value:
371
+ continue
372
+ if value.startswith(("http://", "https://")):
373
+ return value
374
+ return f"https://{value.lstrip('/')}"
375
+ return ""
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ from mcp.server.fastmcp import FastMCP
4
+
5
+ from ..config import DEFAULT_PROFILE
6
+ from ..errors import QingflowApiError, raise_tool_error
7
+ from ..json_types import JSONObject
8
+ from .base import ToolBase
9
+
10
+
11
+ class NavigationTools(ToolBase):
12
+ def register(self, mcp: FastMCP) -> None:
13
+ @mcp.tool()
14
+ def navigation_list_published(profile: str = DEFAULT_PROFILE, page_num: int = 1, page_size: int = 50) -> JSONObject:
15
+ return self.navigation_list_published(profile=profile, page_num=page_num, page_size=page_size)
16
+
17
+ @mcp.tool()
18
+ def navigation_list_draft_page(
19
+ profile: str = DEFAULT_PROFILE,
20
+ page_num: int = 1,
21
+ page_size: int = 50,
22
+ query_key: str | None = None,
23
+ ) -> JSONObject:
24
+ return self.navigation_list_draft_page(profile=profile, page_num=page_num, page_size=page_size, query_key=query_key)
25
+
26
+ @mcp.tool()
27
+ def navigation_list_draft_all(profile: str = DEFAULT_PROFILE, query_key: str | None = None) -> JSONObject:
28
+ return self.navigation_list_draft_all(profile=profile, query_key=query_key)
29
+
30
+ @mcp.tool()
31
+ def navigation_get_detail(profile: str = DEFAULT_PROFILE, navigation_item_id: int = 0, status: str = "draft") -> JSONObject:
32
+ return self.navigation_get_detail(profile=profile, navigation_item_id=navigation_item_id, status=status)
33
+
34
+ @mcp.tool()
35
+ def navigation_get_status(profile: str = DEFAULT_PROFILE) -> JSONObject:
36
+ return self.navigation_get_status(profile=profile)
37
+
38
+ @mcp.tool()
39
+ def navigation_set_visible(profile: str = DEFAULT_PROFILE, payload: JSONObject | None = None) -> JSONObject:
40
+ return self.navigation_set_visible(profile=profile, payload=payload or {})
41
+
42
+ @mcp.tool()
43
+ def navigation_create(profile: str = DEFAULT_PROFILE, payload: JSONObject | None = None) -> JSONObject:
44
+ return self.navigation_create(profile=profile, payload=payload or {})
45
+
46
+ @mcp.tool(description=self._high_risk_tool_description(operation="update", target="navigation item configuration"))
47
+ def navigation_update(profile: str = DEFAULT_PROFILE, navigation_item_id: int = 0, payload: JSONObject | None = None) -> JSONObject:
48
+ return self.navigation_update(profile=profile, navigation_item_id=navigation_item_id, payload=payload or {})
49
+
50
+ @mcp.tool(description=self._high_risk_tool_description(operation="delete", target="navigation item configuration"))
51
+ def navigation_delete(profile: str = DEFAULT_PROFILE, navigation_item_id: int = 0) -> JSONObject:
52
+ return self.navigation_delete(profile=profile, navigation_item_id=navigation_item_id)
53
+
54
+ @mcp.tool()
55
+ def navigation_publish(profile: str = DEFAULT_PROFILE, navigation_id: int = 0) -> JSONObject:
56
+ return self.navigation_publish(profile=profile, navigation_id=navigation_id)
57
+
58
+ @mcp.tool()
59
+ def navigation_reorder(profile: str = DEFAULT_PROFILE, payload: list[JSONObject] | None = None) -> JSONObject:
60
+ return self.navigation_reorder(profile=profile, payload=payload or [])
61
+
62
+ def navigation_list_published(self, *, profile: str, page_num: int = 1, page_size: int = 50) -> JSONObject:
63
+ def runner(session_profile, context):
64
+ result = self.backend.request("GET", context, "/navigation", params={"pageNum": page_num, "pageSize": page_size})
65
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "page": result}
66
+
67
+ return self._run(profile, runner)
68
+
69
+ def navigation_list_draft_page(self, *, profile: str, page_num: int = 1, page_size: int = 50, query_key: str | None = None) -> JSONObject:
70
+ def runner(session_profile, context):
71
+ params: JSONObject = {"pageNum": page_num, "pageSize": page_size}
72
+ if query_key:
73
+ params["queryCondition"] = query_key
74
+ result = self.backend.request("GET", context, "/navigation/page", params=params)
75
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "page": result}
76
+
77
+ return self._run(profile, runner)
78
+
79
+ def navigation_list_draft_all(self, *, profile: str, query_key: str | None = None) -> JSONObject:
80
+ def runner(session_profile, context):
81
+ params: JSONObject = {}
82
+ if query_key:
83
+ params["queryCondition"] = query_key
84
+ result = self.backend.request("GET", context, "/navigation/all", params=params)
85
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "items": result}
86
+
87
+ return self._run(profile, runner)
88
+
89
+ def navigation_get_detail(self, *, profile: str, navigation_item_id: int, status: str = "draft") -> JSONObject:
90
+ self._require_navigation_item_id(navigation_item_id)
91
+
92
+ def runner(session_profile, context):
93
+ result = self.backend.request(
94
+ "GET",
95
+ context,
96
+ f"/navigation/detail/{navigation_item_id}",
97
+ params={"status": status},
98
+ )
99
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "navigation_item_id": navigation_item_id, "result": result}
100
+
101
+ return self._run(profile, runner)
102
+
103
+ def navigation_get_status(self, *, profile: str) -> JSONObject:
104
+ def runner(session_profile, context):
105
+ result = self.backend.request("GET", context, "/navigation/status")
106
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "result": result}
107
+
108
+ return self._run(profile, runner)
109
+
110
+ def navigation_set_visible(self, *, profile: str, payload: JSONObject) -> JSONObject:
111
+ body = self._require_dict(payload)
112
+
113
+ def runner(session_profile, context):
114
+ result = self.backend.request("POST", context, "/navigation/visible", json_body=body)
115
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "result": result}
116
+
117
+ return self._run(profile, runner)
118
+
119
+ def navigation_create(self, *, profile: str, payload: JSONObject) -> JSONObject:
120
+ body = self._require_dict(payload)
121
+
122
+ def runner(session_profile, context):
123
+ result = self.backend.request("POST", context, "/navigation", json_body=body)
124
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "result": result}
125
+
126
+ return self._run(profile, runner)
127
+
128
+ def navigation_update(self, *, profile: str, navigation_item_id: int, payload: JSONObject) -> JSONObject:
129
+ self._require_navigation_item_id(navigation_item_id)
130
+ body = self._require_dict(payload)
131
+
132
+ def runner(session_profile, context):
133
+ result = self.backend.request("PUT", context, f"/navigation/{navigation_item_id}", json_body=body)
134
+ return self._attach_human_review_notice(
135
+ {"profile": profile, "ws_id": session_profile.selected_ws_id, "navigation_item_id": navigation_item_id, "result": result},
136
+ operation="update",
137
+ target="navigation item configuration",
138
+ )
139
+
140
+ return self._run(profile, runner)
141
+
142
+ def navigation_delete(self, *, profile: str, navigation_item_id: int) -> JSONObject:
143
+ self._require_navigation_item_id(navigation_item_id)
144
+
145
+ def runner(session_profile, context):
146
+ result = self.backend.request("DELETE", context, f"/navigation/{navigation_item_id}")
147
+ return self._attach_human_review_notice(
148
+ {"profile": profile, "ws_id": session_profile.selected_ws_id, "navigation_item_id": navigation_item_id, "result": result},
149
+ operation="delete",
150
+ target="navigation item configuration",
151
+ )
152
+
153
+ return self._run(profile, runner)
154
+
155
+ def navigation_publish(self, *, profile: str, navigation_id: int) -> JSONObject:
156
+ if navigation_id <= 0:
157
+ raise_tool_error(QingflowApiError.config_error("navigation_id must be positive"))
158
+
159
+ def runner(session_profile, context):
160
+ result = self.backend.request("POST", context, f"/navigation/publish/{navigation_id}")
161
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "navigation_id": navigation_id, "result": result}
162
+
163
+ return self._run(profile, runner)
164
+
165
+ def navigation_reorder(self, *, profile: str, payload: list[JSONObject]) -> JSONObject:
166
+ if not isinstance(payload, list) or not payload:
167
+ raise_tool_error(QingflowApiError.config_error("payload must be a non-empty array"))
168
+
169
+ def runner(session_profile, context):
170
+ result = self.backend.request("POST", context, "/navigation/ordinal", json_body=payload)
171
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "result": result}
172
+
173
+ return self._run(profile, runner)
174
+
175
+ def _require_navigation_item_id(self, navigation_item_id: int) -> None:
176
+ if navigation_item_id <= 0:
177
+ raise_tool_error(QingflowApiError.config_error("navigation_item_id must be positive"))