@josephyan/qingflow-cli 0.2.0-beta.1000

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 (92) hide show
  1. package/README.md +31 -0
  2. package/docs/local-agent-install.md +309 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +346 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +37 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +649 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +1846 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +16502 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +112 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +539 -0
  21. package/src/qingflow_mcp/cli/commands/chart.py +18 -0
  22. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  23. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  24. package/src/qingflow_mcp/cli/commands/portal.py +25 -0
  25. package/src/qingflow_mcp/cli/commands/record.py +331 -0
  26. package/src/qingflow_mcp/cli/commands/repo.py +80 -0
  27. package/src/qingflow_mcp/cli/commands/task.py +141 -0
  28. package/src/qingflow_mcp/cli/commands/view.py +18 -0
  29. package/src/qingflow_mcp/cli/commands/workspace.py +110 -0
  30. package/src/qingflow_mcp/cli/context.py +60 -0
  31. package/src/qingflow_mcp/cli/formatters.py +573 -0
  32. package/src/qingflow_mcp/cli/json_io.py +50 -0
  33. package/src/qingflow_mcp/cli/main.py +186 -0
  34. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  35. package/src/qingflow_mcp/cli/terminal_ui.py +173 -0
  36. package/src/qingflow_mcp/config.py +407 -0
  37. package/src/qingflow_mcp/errors.py +66 -0
  38. package/src/qingflow_mcp/id_utils.py +49 -0
  39. package/src/qingflow_mcp/import_store.py +121 -0
  40. package/src/qingflow_mcp/json_types.py +18 -0
  41. package/src/qingflow_mcp/list_type_labels.py +76 -0
  42. package/src/qingflow_mcp/public_surface.py +243 -0
  43. package/src/qingflow_mcp/repository_store.py +71 -0
  44. package/src/qingflow_mcp/response_trim.py +841 -0
  45. package/src/qingflow_mcp/server.py +216 -0
  46. package/src/qingflow_mcp/server_app_builder.py +543 -0
  47. package/src/qingflow_mcp/server_app_user.py +386 -0
  48. package/src/qingflow_mcp/session_store.py +369 -0
  49. package/src/qingflow_mcp/solution/__init__.py +6 -0
  50. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  51. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  52. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  53. package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
  54. package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
  55. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  56. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  57. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  58. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  59. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  60. package/src/qingflow_mcp/solution/design_session.py +222 -0
  61. package/src/qingflow_mcp/solution/design_store.py +100 -0
  62. package/src/qingflow_mcp/solution/executor.py +2398 -0
  63. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  64. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  65. package/src/qingflow_mcp/solution/run_store.py +244 -0
  66. package/src/qingflow_mcp/solution/spec_models.py +855 -0
  67. package/src/qingflow_mcp/tools/__init__.py +1 -0
  68. package/src/qingflow_mcp/tools/ai_builder_tools.py +3449 -0
  69. package/src/qingflow_mcp/tools/app_tools.py +926 -0
  70. package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
  71. package/src/qingflow_mcp/tools/auth_tools.py +1133 -0
  72. package/src/qingflow_mcp/tools/base.py +281 -0
  73. package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
  74. package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
  75. package/src/qingflow_mcp/tools/directory_tools.py +675 -0
  76. package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
  77. package/src/qingflow_mcp/tools/file_tools.py +409 -0
  78. package/src/qingflow_mcp/tools/import_tools.py +2223 -0
  79. package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
  80. package/src/qingflow_mcp/tools/package_tools.py +326 -0
  81. package/src/qingflow_mcp/tools/portal_tools.py +158 -0
  82. package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
  83. package/src/qingflow_mcp/tools/record_tools.py +14291 -0
  84. package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
  85. package/src/qingflow_mcp/tools/resource_read_tools.py +503 -0
  86. package/src/qingflow_mcp/tools/role_tools.py +112 -0
  87. package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
  88. package/src/qingflow_mcp/tools/task_context_tools.py +2986 -0
  89. package/src/qingflow_mcp/tools/task_tools.py +889 -0
  90. package/src/qingflow_mcp/tools/view_tools.py +335 -0
  91. package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
  92. package/src/qingflow_mcp/tools/workspace_tools.py +266 -0
@@ -0,0 +1,503 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+ from ..errors import QingflowApiError, raise_tool_error
7
+ from ..json_types import JSONObject
8
+ from ..list_type_labels import SYSTEM_VIEW_DEFINITIONS
9
+ from .app_tools import _analysis_supported_for_view_type
10
+ from .base import ToolBase, tool_cn_name
11
+ from .qingbi_report_tools import QingbiReportTools
12
+
13
+
14
+ class ResourceReadTools(ToolBase):
15
+ """资源读取工具(中文名:统一只读聚合)。
16
+
17
+ 类型:只读聚合工具。
18
+ 主要职责:
19
+ 1. 聚合应用、视图、图表等读取能力;
20
+ 2. 为上层流程提供统一资源读取入口;
21
+ 3. 避免多工具重复初始化造成的调用复杂度。
22
+ """
23
+
24
+ def __init__(self, sessions, backend) -> None:
25
+ """执行内部辅助逻辑。"""
26
+ super().__init__(sessions, backend)
27
+ self.charts = QingbiReportTools(sessions, backend)
28
+
29
+ @tool_cn_name("资源读取-门户列表")
30
+ def portal_list(self, *, profile: str) -> JSONObject:
31
+ """执行门户相关逻辑。"""
32
+ def runner(session_profile, context):
33
+ raw_items = self.backend.request("GET", context, "/dash")
34
+ items = _normalize_portal_list_items(raw_items)
35
+ return {
36
+ "profile": profile,
37
+ "ws_id": session_profile.selected_ws_id,
38
+ "ok": True,
39
+ "warnings": [],
40
+ "verification": {"portal_list_loaded": True},
41
+ "data": {
42
+ "items": items,
43
+ "total": len(items),
44
+ },
45
+ }
46
+
47
+ return self._run(profile, runner)
48
+
49
+ @tool_cn_name("资源读取-门户详情")
50
+ def portal_get(self, *, profile: str, dash_key: str) -> JSONObject:
51
+ """执行门户相关逻辑。"""
52
+ self._require_dash_key(dash_key)
53
+
54
+ def runner(session_profile, context):
55
+ result = self.backend.request("GET", context, f"/dash/{dash_key}", params={"beingDraft": False})
56
+ dash_name = str(result.get("dashName") or "").strip() or None
57
+ dash_icon = str(result.get("dashIcon") or "").strip() or None
58
+ package_tag_ids = [
59
+ tag_id
60
+ for tag_id in (
61
+ _coerce_positive_int((tag or {}).get("tagId"))
62
+ for tag in (result.get("tags") or [])
63
+ if isinstance(tag, dict)
64
+ )
65
+ if tag_id is not None
66
+ ]
67
+ components = _normalize_user_portal_components(result.get("components"))
68
+ return {
69
+ "profile": profile,
70
+ "ws_id": session_profile.selected_ws_id,
71
+ "ok": True,
72
+ "warnings": [],
73
+ "verification": {"portal_exists": True},
74
+ "data": {
75
+ "dash_key": dash_key,
76
+ "dash_name": dash_name,
77
+ "dash_icon": dash_icon,
78
+ "package_tag_ids": package_tag_ids,
79
+ "component_count": len(components),
80
+ "components": components,
81
+ },
82
+ }
83
+
84
+ return self._run(profile, runner)
85
+
86
+ @tool_cn_name("资源读取-视图详情")
87
+ def view_get(self, *, profile: str, view_id: str) -> JSONObject:
88
+ """执行视图相关逻辑。"""
89
+ self._require_view_id(view_id)
90
+ view_key = _extract_custom_view_key(view_id)
91
+ system_view = _lookup_system_view_descriptor(view_id)
92
+ if view_key is None and system_view is None:
93
+ raise_tool_error(
94
+ QingflowApiError.config_error(
95
+ "view_get only accepts accessible view_id values such as `custom:VIEW_KEY` or `system:all`; use app_get or portal_get first to find a valid view_id."
96
+ )
97
+ )
98
+ if system_view is not None:
99
+ return self._run(
100
+ profile,
101
+ lambda session_profile, _context: {
102
+ "profile": profile,
103
+ "ws_id": session_profile.selected_ws_id,
104
+ "ok": True,
105
+ "warnings": [
106
+ {
107
+ "code": "VIEW_APP_KEY_UNRESOLVED",
108
+ "message": f"view_get could not resolve app_key for system view `{view_id}`; keep using the app_key from the parent app context.",
109
+ }
110
+ ],
111
+ "verification": {
112
+ "view_exists": True,
113
+ "descriptor_only": True,
114
+ },
115
+ "data": {
116
+ "app_key": None,
117
+ "view_id": view_id,
118
+ "view_key": None,
119
+ "view_name": system_view["view_name"],
120
+ "view_type": system_view["view_type"],
121
+ "visible_columns": [],
122
+ "analysis_supported": system_view["analysis_supported"],
123
+ },
124
+ },
125
+ )
126
+
127
+ def runner(session_profile, context):
128
+ warnings: list[JSONObject] = []
129
+ verification = {
130
+ "view_exists": True,
131
+ "questions_verified": True,
132
+ }
133
+ config = self.backend.request("GET", context, f"/view/{view_key}/viewConfig")
134
+ base_info = self.backend.request("GET", context, f"/view/{view_key}/viewConfig/baseInfo")
135
+ questions: list[dict[str, Any]] = []
136
+ try:
137
+ questions_payload = self.backend.request("GET", context, f"/view/{view_key}/question")
138
+ if isinstance(questions_payload, list):
139
+ questions = [deepcopy(item) for item in questions_payload if isinstance(item, dict)]
140
+ except QingflowApiError:
141
+ verification["questions_verified"] = False
142
+ warnings.append(
143
+ {
144
+ "code": "VIEW_QUESTIONS_UNAVAILABLE",
145
+ "message": "view_get could not load visible columns because question readback is unavailable.",
146
+ }
147
+ )
148
+
149
+ raw_view_type = (
150
+ str(base_info.get("viewgraphType") or "").strip()
151
+ or str(config.get("viewgraphType") or config.get("viewType") or "").strip()
152
+ )
153
+ resolved_app_key = str(base_info.get("appKey") or config.get("appKey") or "").strip() or None
154
+ if not resolved_app_key:
155
+ resolved_app_key = self._resolve_app_key_from_view_form(context=context, view_key=view_key)
156
+ if not resolved_app_key:
157
+ resolved_app_key = self._resolve_app_key_from_form_id(
158
+ context=context,
159
+ form_id=_coerce_positive_int(base_info.get("formId") or config.get("formId")),
160
+ )
161
+ if not resolved_app_key:
162
+ warnings.append(
163
+ {
164
+ "code": "VIEW_APP_KEY_UNRESOLVED",
165
+ "message": f"view_get could not resolve app_key for `{view_id}` from view metadata; keep using the app_key from the parent app or portal context.",
166
+ }
167
+ )
168
+ return {
169
+ "profile": profile,
170
+ "ws_id": session_profile.selected_ws_id,
171
+ "ok": True,
172
+ "warnings": warnings,
173
+ "verification": verification,
174
+ "data": {
175
+ "app_key": resolved_app_key,
176
+ "view_id": view_id,
177
+ "view_key": view_key,
178
+ "view_name": str(
179
+ base_info.get("viewgraphName") or config.get("viewgraphName") or config.get("viewName") or view_key
180
+ ).strip()
181
+ or view_key,
182
+ "view_type": _normalize_view_type(raw_view_type),
183
+ "visible_columns": [
184
+ str(item.get("queTitle") or item.get("title") or "").strip()
185
+ for item in questions
186
+ if str(item.get("queTitle") or item.get("title") or "").strip()
187
+ ],
188
+ "analysis_supported": _analysis_supported_for_view_type(raw_view_type or None),
189
+ },
190
+ }
191
+
192
+ return self._run(profile, runner)
193
+
194
+ @tool_cn_name("资源读取-报表详情")
195
+ def chart_get(self, *, profile: str, chart_id: str) -> JSONObject:
196
+ """执行图表相关逻辑。"""
197
+ self._require_chart_id(chart_id)
198
+
199
+ def runner(session_profile, _context):
200
+ base = self.charts.qingbi_report_get_base(profile=profile, chart_id=chart_id).get("result") or {}
201
+ warnings: list[JSONObject] = []
202
+ verification = {
203
+ "chart_exists": True,
204
+ "chart_data_loaded": False,
205
+ "chart_config_loaded": False,
206
+ }
207
+ data: Any = None
208
+ data_config: dict[str, Any] = {}
209
+ try:
210
+ data = self.charts.qingbi_report_get_data(profile=profile, chart_id=chart_id, payload={}).get("result") or {}
211
+ verification["chart_data_loaded"] = True
212
+ if isinstance(data, dict) and isinstance(data.get("config"), dict):
213
+ data_config = deepcopy(data.get("config"))
214
+ verification["chart_config_loaded"] = True
215
+ except (QingflowApiError, RuntimeError) as error:
216
+ api_error = error if isinstance(error, QingflowApiError) else None
217
+ warnings.append(
218
+ {
219
+ "code": "CHART_DATA_UNAVAILABLE",
220
+ "message": "chart_get could not load chart data; returning chart metadata and config when available.",
221
+ "backend_code": api_error.backend_code if api_error is not None else None,
222
+ "http_status": api_error.http_status if api_error is not None else None,
223
+ }
224
+ )
225
+ if not data_config:
226
+ try:
227
+ config_result = self.charts.qingbi_report_get_config(profile=profile, chart_id=chart_id).get("result") or {}
228
+ except (QingflowApiError, RuntimeError):
229
+ config_result = {}
230
+ if isinstance(config_result, dict) and config_result:
231
+ data_config = deepcopy(config_result)
232
+ verification["chart_config_loaded"] = True
233
+ data_payload: dict[str, Any] = {
234
+ "chart_id": chart_id,
235
+ "chart_name": str(base.get("chartName") or base.get("name") or chart_id).strip() or chart_id,
236
+ "chart_type": str(base.get("chartType") or data_config.get("chartType") or "").strip() or None,
237
+ "data_source_type": str(base.get("dataSourceType") or "").strip() or None,
238
+ "data_source_id": str(base.get("dataSourceId") or "").strip() or None,
239
+ }
240
+ if verification["chart_data_loaded"]:
241
+ data_payload["data"] = deepcopy(data) if isinstance(data, dict) else {"value": data}
242
+ if data_config and not verification["chart_data_loaded"]:
243
+ data_payload["config"] = deepcopy(data_config)
244
+ return {
245
+ "profile": profile,
246
+ "ws_id": session_profile.selected_ws_id,
247
+ "ok": True,
248
+ "warnings": warnings,
249
+ "verification": verification,
250
+ "data": data_payload,
251
+ }
252
+
253
+ return self._run(profile, runner)
254
+
255
+ def _require_dash_key(self, dash_key: str) -> None:
256
+ """执行内部辅助逻辑。"""
257
+ if not dash_key:
258
+ raise_tool_error(QingflowApiError.config_error("dash_key is required"))
259
+
260
+ def _require_view_id(self, view_id: str) -> None:
261
+ """执行内部辅助逻辑。"""
262
+ if not view_id:
263
+ raise_tool_error(QingflowApiError.config_error("view_id is required"))
264
+
265
+ def _require_chart_id(self, chart_id: str) -> None:
266
+ """执行内部辅助逻辑。"""
267
+ if not chart_id:
268
+ raise_tool_error(QingflowApiError.config_error("chart_id is required"))
269
+
270
+ def _resolve_app_key_from_view_form(self, *, context: Any, view_key: str) -> str | None:
271
+ try:
272
+ form_payload = self.backend.request("GET", context, f"/view/{view_key}/form")
273
+ except QingflowApiError:
274
+ return None
275
+ if not isinstance(form_payload, dict):
276
+ return None
277
+ app_key = str(form_payload.get("appKey") or "").strip()
278
+ return app_key or None
279
+
280
+ def _resolve_app_key_from_form_id(self, *, context: Any, form_id: int | None) -> str | None:
281
+ """执行内部辅助逻辑。"""
282
+ if form_id is None:
283
+ return None
284
+ try:
285
+ payload = self.backend.request("GET", context, "/tag/apps")
286
+ except QingflowApiError:
287
+ payload = None
288
+ app_key = _find_visible_app_key_by_form_id(payload, form_id=form_id)
289
+ if app_key:
290
+ return app_key
291
+ page_num = 1
292
+ page_size = 200
293
+ while page_num <= 20:
294
+ try:
295
+ page = self.backend.request(
296
+ "GET",
297
+ context,
298
+ "/app/item",
299
+ params={"pageNum": page_num, "pageSize": page_size},
300
+ )
301
+ except QingflowApiError:
302
+ return None
303
+ items = page.get("list") if isinstance(page, dict) else []
304
+ if not isinstance(items, list) or not items:
305
+ return None
306
+ for item in items:
307
+ if not isinstance(item, dict):
308
+ continue
309
+ if _coerce_positive_int(item.get("formId") or item.get("form_id")) != form_id:
310
+ continue
311
+ app_key = str(item.get("appKey") or item.get("app_key") or "").strip()
312
+ if app_key:
313
+ return app_key
314
+ total = _coerce_positive_int(page.get("total")) if isinstance(page, dict) else None
315
+ if total is not None and page_num * page_size >= total:
316
+ break
317
+ page_num += 1
318
+ return None
319
+
320
+
321
+ def _normalize_portal_list_items(raw_items: Any) -> list[dict[str, Any]]:
322
+ if not isinstance(raw_items, list):
323
+ return []
324
+ items: list[dict[str, Any]] = []
325
+ for item in raw_items:
326
+ if not isinstance(item, dict):
327
+ continue
328
+ dash_key = str(item.get("dashKey") or "").strip()
329
+ dash_name = str(item.get("dashName") or "").strip()
330
+ dash_icon = str(item.get("dashIcon") or "").strip() or None
331
+ package_tag_ids = [
332
+ tag_id
333
+ for tag_id in (
334
+ _coerce_positive_int((tag or {}).get("tagId"))
335
+ for tag in (item.get("tags") or [])
336
+ if isinstance(tag, dict)
337
+ )
338
+ if tag_id is not None
339
+ ]
340
+ if not any((dash_key, dash_name, dash_icon, package_tag_ids)):
341
+ continue
342
+ items.append(
343
+ {
344
+ "dash_key": dash_key or None,
345
+ "dash_name": dash_name or None,
346
+ "dash_icon": dash_icon,
347
+ "package_tag_ids": package_tag_ids,
348
+ }
349
+ )
350
+ return items
351
+
352
+
353
+ def _normalize_user_portal_components(components: Any) -> list[dict[str, Any]]:
354
+ if not isinstance(components, list):
355
+ return []
356
+ items: list[dict[str, Any]] = []
357
+ config_key_map = {
358
+ "grid": "gridConfig",
359
+ "link": "linkConfig",
360
+ "text": "textConfig",
361
+ "filter": "filterConfig",
362
+ "chart": "chartConfig",
363
+ "view": "viewgraphConfig",
364
+ }
365
+ for index, component in enumerate(components):
366
+ if not isinstance(component, dict):
367
+ continue
368
+ source_type = _normalize_portal_component_source_type(component.get("type"))
369
+ title = _extract_portal_component_title(component, source_type=source_type)
370
+ summary: dict[str, Any] = {
371
+ "order": index,
372
+ "source_type": source_type,
373
+ "title": title,
374
+ }
375
+ position = component.get("position")
376
+ if isinstance(position, dict):
377
+ summary["position"] = deepcopy(position)
378
+ config_key = config_key_map.get(source_type, "")
379
+ config = component.get(config_key) if isinstance(component.get(config_key), dict) else {}
380
+ if source_type == "chart":
381
+ summary["chart_ref"] = {
382
+ "chart_id": str(config.get("biChartId") or "").strip() or None,
383
+ "chart_name": str(config.get("chartComponentTitle") or title or "").strip() or None,
384
+ }
385
+ elif source_type == "view":
386
+ view_key = str(config.get("viewgraphKey") or "").strip() or None
387
+ summary["view_ref"] = {
388
+ "app_key": str(config.get("appKey") or "").strip() or None,
389
+ "view_id": f"custom:{view_key}" if view_key else None,
390
+ "view_key": view_key,
391
+ "view_name": str(config.get("viewgraphName") or title or "").strip() or None,
392
+ }
393
+ elif source_type in {"grid", "link", "text", "filter"} and config:
394
+ summary["config"] = deepcopy(config)
395
+ items.append(summary)
396
+ return items
397
+
398
+
399
+ def _normalize_portal_component_source_type(value: Any) -> str:
400
+ raw = str(value or "").strip()
401
+ mapping = {
402
+ "2": "grid",
403
+ "4": "link",
404
+ "5": "text",
405
+ "6": "filter",
406
+ "9": "chart",
407
+ "10": "view",
408
+ "grid": "grid",
409
+ "link": "link",
410
+ "text": "text",
411
+ "filter": "filter",
412
+ "chart": "chart",
413
+ "bi": "chart",
414
+ "view": "view",
415
+ "viewgraph": "view",
416
+ }
417
+ return mapping.get(raw, raw.lower() or "unknown")
418
+
419
+
420
+ def _extract_portal_component_title(component: dict[str, Any], *, source_type: str) -> str | None:
421
+ config_key_map = {
422
+ "grid": "gridConfig",
423
+ "link": "linkConfig",
424
+ "text": "textConfig",
425
+ "filter": "filterConfig",
426
+ "chart": "chartConfig",
427
+ "view": "viewgraphConfig",
428
+ }
429
+ config_key = config_key_map.get(source_type, "")
430
+ config = component.get(config_key) if isinstance(component.get(config_key), dict) else {}
431
+ title_candidates = {
432
+ "grid": ["gridTitle"],
433
+ "link": ["title", "linkTitle"],
434
+ "text": ["title", "textTitle"],
435
+ "filter": ["title", "filterTitle"],
436
+ "chart": ["chartComponentTitle", "componentTitle", "title"],
437
+ "view": ["componentTitle", "viewgraphName", "title"],
438
+ }
439
+ for key in title_candidates.get(source_type, []):
440
+ title = str(config.get(key) or "").strip()
441
+ if title:
442
+ return title
443
+ return None
444
+
445
+
446
+ def _extract_custom_view_key(view_id: str) -> str | None:
447
+ value = str(view_id or "").strip()
448
+ if not value.startswith("custom:"):
449
+ return None
450
+ view_key = value.split(":", 1)[1].strip()
451
+ return view_key or None
452
+
453
+
454
+ def _lookup_system_view_descriptor(view_id: str) -> dict[str, Any] | None:
455
+ normalized = str(view_id or "").strip()
456
+ for system_view_id, _list_type, name in SYSTEM_VIEW_DEFINITIONS:
457
+ if system_view_id != normalized:
458
+ continue
459
+ return {
460
+ "view_name": name,
461
+ "view_type": "system",
462
+ "analysis_supported": True,
463
+ }
464
+ return None
465
+
466
+
467
+ def _normalize_view_type(view_type: Any) -> str | None:
468
+ value = str(view_type or "").strip()
469
+ if not value:
470
+ return None
471
+ if value.lower().endswith("view"):
472
+ value = value[:-4]
473
+ return value or None
474
+
475
+
476
+ def _coerce_positive_int(value: Any) -> int | None:
477
+ try:
478
+ number = int(value)
479
+ except (TypeError, ValueError):
480
+ return None
481
+ return number if number > 0 else None
482
+
483
+
484
+ def _find_visible_app_key_by_form_id(payload: Any, *, form_id: int) -> str | None:
485
+ if isinstance(payload, list):
486
+ for item in payload:
487
+ resolved = _find_visible_app_key_by_form_id(item, form_id=form_id)
488
+ if resolved:
489
+ return resolved
490
+ return None
491
+ if not isinstance(payload, dict):
492
+ return None
493
+ candidate_form_id = _coerce_positive_int(payload.get("formId") or payload.get("form_id"))
494
+ if candidate_form_id == form_id:
495
+ app_key = str(payload.get("appKey") or payload.get("app_key") or "").strip()
496
+ if app_key:
497
+ return app_key
498
+ for value in payload.values():
499
+ if isinstance(value, (list, dict)):
500
+ resolved = _find_visible_app_key_by_form_id(value, form_id=form_id)
501
+ if resolved:
502
+ return resolved
503
+ return None
@@ -0,0 +1,112 @@
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, tool_cn_name
9
+
10
+
11
+ class RoleTools(ToolBase):
12
+ """角色工具(中文名:角色检索与创建)。
13
+
14
+ 类型:组织权限工具。
15
+ 主要职责:
16
+ 1. 搜索角色并提供候选列表;
17
+ 2. 创建角色并返回标准角色信息;
18
+ 3. 服务流程节点与权限配置场景。
19
+ """
20
+
21
+ def register(self, mcp: FastMCP) -> None:
22
+ """注册当前工具到 MCP 服务。"""
23
+ @mcp.tool()
24
+ def role_search(
25
+ profile: str = DEFAULT_PROFILE,
26
+ keyword: str = "",
27
+ page_num: int = 1,
28
+ page_size: int = 50,
29
+ ) -> JSONObject:
30
+ return self.role_search(profile=profile, keyword=keyword, page_num=page_num, page_size=page_size)
31
+
32
+ @mcp.tool()
33
+ def role_create(profile: str = DEFAULT_PROFILE, payload: JSONObject | None = None) -> JSONObject:
34
+ return self.role_create(profile=profile, payload=payload or {})
35
+
36
+ @mcp.tool(description=self._high_risk_tool_description(operation="update", target="role configuration"))
37
+ def role_update(
38
+ profile: str = DEFAULT_PROFILE,
39
+ role_id: int = 0,
40
+ payload: JSONObject | None = None,
41
+ ) -> JSONObject:
42
+ return self.role_update(profile=profile, role_id=role_id, payload=payload or {})
43
+
44
+ @mcp.tool(description=self._high_risk_tool_description(operation="delete", target="roles and assignments"))
45
+ def role_delete(profile: str = DEFAULT_PROFILE, role_ids: list[int] | None = None) -> JSONObject:
46
+ return self.role_delete(profile=profile, role_ids=role_ids or [])
47
+
48
+ @tool_cn_name("角色搜索")
49
+ def role_search(self, *, profile: str, keyword: str, page_num: int, page_size: int) -> JSONObject:
50
+ """执行角色相关逻辑。"""
51
+ if page_num <= 0 or page_size <= 0:
52
+ raise_tool_error(QingflowApiError.config_error("page_num and page_size must be positive"))
53
+
54
+ def runner(session_profile, context):
55
+ result = self.backend.request(
56
+ "GET",
57
+ context,
58
+ "/contact/roleByPage",
59
+ params={"keyword": keyword, "pageNum": page_num, "pageSize": page_size},
60
+ )
61
+ return {
62
+ "profile": profile,
63
+ "ws_id": session_profile.selected_ws_id,
64
+ "keyword": keyword,
65
+ "page": result,
66
+ }
67
+
68
+ return self._run(profile, runner)
69
+
70
+ @tool_cn_name("创建角色")
71
+ def role_create(self, *, profile: str, payload: JSONObject) -> JSONObject:
72
+ """执行角色相关逻辑。"""
73
+ body = self._require_dict(payload)
74
+
75
+ def runner(session_profile, context):
76
+ result = self.backend.request("POST", context, "/contact/role", json_body=body)
77
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "result": result}
78
+
79
+ return self._run(profile, runner)
80
+
81
+ @tool_cn_name("更新角色")
82
+ def role_update(self, *, profile: str, role_id: int, payload: JSONObject) -> JSONObject:
83
+ """执行角色相关逻辑。"""
84
+ if role_id <= 0:
85
+ raise_tool_error(QingflowApiError.config_error("role_id must be positive"))
86
+ body = self._require_dict(payload)
87
+
88
+ def runner(session_profile, context):
89
+ result = self.backend.request("POST", context, f"/contact/role/{role_id}", json_body=body)
90
+ return self._attach_human_review_notice(
91
+ {"profile": profile, "ws_id": session_profile.selected_ws_id, "role_id": role_id, "result": result},
92
+ operation="update",
93
+ target="role configuration",
94
+ )
95
+
96
+ return self._run(profile, runner)
97
+
98
+ @tool_cn_name("删除角色")
99
+ def role_delete(self, *, profile: str, role_ids: list[int]) -> JSONObject:
100
+ """执行角色相关逻辑。"""
101
+ if not role_ids or any(role_id <= 0 for role_id in role_ids):
102
+ raise_tool_error(QingflowApiError.config_error("role_ids must be a non-empty array of positive integers"))
103
+
104
+ def runner(session_profile, context):
105
+ result = self.backend.request("DELETE", context, "/contact/role", json_body=role_ids)
106
+ return self._attach_human_review_notice(
107
+ {"profile": profile, "ws_id": session_profile.selected_ws_id, "role_ids": role_ids, "result": result},
108
+ operation="delete",
109
+ target="roles and assignments",
110
+ )
111
+
112
+ return self._run(profile, runner)