@josephyan/qingflow-app-builder-mcp 0.2.0-beta.4 → 0.2.0-beta.41
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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +3 -1
- package/skills/qingflow-app-builder/SKILL.md +154 -22
- package/skills/qingflow-app-builder/references/create-app.md +51 -21
- package/skills/qingflow-app-builder/references/environments.md +1 -1
- package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
- package/skills/qingflow-app-builder/references/gotchas.md +28 -1
- package/skills/qingflow-app-builder/references/solution-playbooks.md +14 -12
- package/skills/qingflow-app-builder/references/tool-selection.md +45 -17
- package/skills/qingflow-app-builder/references/update-flow.md +112 -25
- package/skills/qingflow-app-builder/references/update-layout.md +11 -24
- package/skills/qingflow-app-builder/references/update-schema.md +1 -23
- package/skills/qingflow-app-builder/references/update-views.md +87 -21
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/backend_client.py +189 -0
- package/src/qingflow_mcp/builder_facade/models.py +584 -1
- package/src/qingflow_mcp/builder_facade/service.py +4698 -262
- package/src/qingflow_mcp/config.py +39 -0
- package/src/qingflow_mcp/import_store.py +121 -0
- package/src/qingflow_mcp/list_type_labels.py +24 -0
- package/src/qingflow_mcp/server.py +131 -16
- package/src/qingflow_mcp/server_app_builder.py +132 -72
- package/src/qingflow_mcp/server_app_user.py +143 -187
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +14 -4
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +41 -2
- package/src/qingflow_mcp/solution/executor.py +44 -7
- package/src/qingflow_mcp/tools/ai_builder_tools.py +1567 -144
- package/src/qingflow_mcp/tools/app_tools.py +243 -14
- package/src/qingflow_mcp/tools/approval_tools.py +411 -76
- package/src/qingflow_mcp/tools/directory_tools.py +203 -31
- package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
- package/src/qingflow_mcp/tools/file_tools.py +1 -0
- package/src/qingflow_mcp/tools/import_tools.py +1164 -0
- package/src/qingflow_mcp/tools/portal_tools.py +31 -0
- package/src/qingflow_mcp/tools/record_tools.py +4943 -1025
- package/src/qingflow_mcp/tools/task_context_tools.py +1335 -0
- package/src/qingflow_mcp/tools/task_tools.py +376 -225
- package/src/qingflow_mcp/tools/workflow_tools.py +78 -4
|
@@ -9,7 +9,7 @@ from ..backend_client import BackendRequestContext
|
|
|
9
9
|
from ..config import DEFAULT_PROFILE
|
|
10
10
|
from ..errors import QingflowApiError, raise_tool_error
|
|
11
11
|
from ..json_types import JSONObject
|
|
12
|
-
from ..list_type_labels import get_app_publish_status_label
|
|
12
|
+
from ..list_type_labels import SYSTEM_VIEW_DEFINITIONS, get_app_publish_status_label
|
|
13
13
|
from .base import ToolBase
|
|
14
14
|
|
|
15
15
|
|
|
@@ -23,6 +23,10 @@ class AppTools(ToolBase):
|
|
|
23
23
|
def app_search(profile: str = DEFAULT_PROFILE, keyword: str = "", page_num: int = 1, page_size: int = 50) -> JSONObject:
|
|
24
24
|
return self.app_search(profile=profile, keyword=keyword, page_num=page_num, page_size=page_size)
|
|
25
25
|
|
|
26
|
+
@mcp.tool()
|
|
27
|
+
def app_get(profile: str = DEFAULT_PROFILE, app_key: str = "") -> JSONObject:
|
|
28
|
+
return self.app_get(profile=profile, app_key=app_key)
|
|
29
|
+
|
|
26
30
|
@mcp.tool()
|
|
27
31
|
def app_get_base(profile: str = DEFAULT_PROFILE, app_key: str = "", include_raw: bool = False) -> JSONObject:
|
|
28
32
|
return self.app_get_base(profile=profile, app_key=app_key, include_raw=include_raw)
|
|
@@ -76,14 +80,20 @@ class AppTools(ToolBase):
|
|
|
76
80
|
return self.app_publish(profile=profile, app_key=app_key, payload=payload or {})
|
|
77
81
|
|
|
78
82
|
def app_list(self, *, profile: str, ship_auth: bool = False) -> JSONObject:
|
|
79
|
-
"""
|
|
83
|
+
"""List current-user visible apps in the selected workspace."""
|
|
80
84
|
def runner(session_profile, context):
|
|
81
85
|
result = self.backend.request("GET", context, "/tag/apps")
|
|
82
|
-
|
|
86
|
+
items, source_shape = self._extract_visible_apps(result)
|
|
87
|
+
response = {
|
|
83
88
|
"profile": profile,
|
|
84
89
|
"ws_id": session_profile.selected_ws_id,
|
|
85
|
-
"items":
|
|
90
|
+
"items": items,
|
|
91
|
+
"count": len(items),
|
|
92
|
+
"source_shape": source_shape,
|
|
86
93
|
}
|
|
94
|
+
if ship_auth:
|
|
95
|
+
response["raw"] = result
|
|
96
|
+
return response
|
|
87
97
|
|
|
88
98
|
return self._run(profile, runner)
|
|
89
99
|
|
|
@@ -98,19 +108,20 @@ class AppTools(ToolBase):
|
|
|
98
108
|
|
|
99
109
|
result = self.backend.request("GET", context, "/app/item", params=params)
|
|
100
110
|
|
|
101
|
-
# Extract app list from the response
|
|
102
111
|
apps = []
|
|
103
112
|
if isinstance(result, dict):
|
|
104
113
|
items = result.get("list", [])
|
|
105
114
|
for item in items:
|
|
106
115
|
if isinstance(item, dict):
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
116
|
+
normalized = self._normalize_visible_app(
|
|
117
|
+
item,
|
|
118
|
+
package_tag_id=_coerce_positive_int(item.get("tagId")),
|
|
119
|
+
package_name=str(item.get("tagName") or "").strip() or None,
|
|
120
|
+
group_id=_coerce_positive_int(item.get("groupId")),
|
|
121
|
+
group_name=str(item.get("groupName") or "").strip() or None,
|
|
122
|
+
)
|
|
123
|
+
if normalized is not None:
|
|
124
|
+
apps.append(normalized)
|
|
114
125
|
|
|
115
126
|
return {
|
|
116
127
|
"profile": profile,
|
|
@@ -119,11 +130,56 @@ class AppTools(ToolBase):
|
|
|
119
130
|
"page_num": page_num,
|
|
120
131
|
"page_size": page_size,
|
|
121
132
|
"total": result.get("total") if isinstance(result, dict) else len(apps),
|
|
133
|
+
"items": apps,
|
|
122
134
|
"apps": apps,
|
|
123
135
|
}
|
|
124
136
|
|
|
125
137
|
return self._run(profile, runner)
|
|
126
138
|
|
|
139
|
+
def app_get(self, *, profile: str, app_key: str) -> JSONObject:
|
|
140
|
+
self._require_app_key(app_key)
|
|
141
|
+
|
|
142
|
+
def runner(session_profile, context):
|
|
143
|
+
warnings: list[JSONObject] = []
|
|
144
|
+
app_name = app_key
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
base_info = self.backend.request("GET", context, f"/app/{app_key}/baseInfo")
|
|
148
|
+
if isinstance(base_info, dict):
|
|
149
|
+
app_name = (
|
|
150
|
+
str(base_info.get("formTitle") or base_info.get("title") or base_info.get("appName") or app_key).strip()
|
|
151
|
+
or app_key
|
|
152
|
+
)
|
|
153
|
+
except QingflowApiError as exc:
|
|
154
|
+
if exc.backend_code not in {40002, 40027}:
|
|
155
|
+
raise
|
|
156
|
+
warnings.append(
|
|
157
|
+
{
|
|
158
|
+
"code": "APP_BASE_INFO_UNAVAILABLE",
|
|
159
|
+
"message": f"app_get could not load base info for {app_key}; using app_key as app_name.",
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
can_create = self._probe_create_access(context, app_key)
|
|
164
|
+
accessible_views = self._resolve_accessible_system_views(context, app_key)
|
|
165
|
+
accessible_views.extend(self._resolve_accessible_custom_views(context, app_key))
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
"profile": profile,
|
|
169
|
+
"ws_id": session_profile.selected_ws_id,
|
|
170
|
+
"ok": True,
|
|
171
|
+
"request_route": {"base_url": context.base_url, "qf_version": context.qf_version},
|
|
172
|
+
"warnings": warnings,
|
|
173
|
+
"data": {
|
|
174
|
+
"app_key": app_key,
|
|
175
|
+
"app_name": app_name,
|
|
176
|
+
"can_create": can_create,
|
|
177
|
+
"accessible_views": accessible_views,
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return self._run(profile, runner)
|
|
182
|
+
|
|
127
183
|
def app_get_base(self, *, profile: str, app_key: str, include_raw: bool = False) -> JSONObject:
|
|
128
184
|
self._require_app_key(app_key)
|
|
129
185
|
|
|
@@ -237,7 +293,7 @@ class AppTools(ToolBase):
|
|
|
237
293
|
|
|
238
294
|
def runner(session_profile, context):
|
|
239
295
|
attempted_contexts = [context]
|
|
240
|
-
if context.qf_version is not None
|
|
296
|
+
if context.qf_version is not None:
|
|
241
297
|
attempted_contexts.append(
|
|
242
298
|
BackendRequestContext(
|
|
243
299
|
base_url=context.base_url,
|
|
@@ -270,7 +326,6 @@ class AppTools(ToolBase):
|
|
|
270
326
|
is_retryable_404 = (
|
|
271
327
|
error.http_status == 404
|
|
272
328
|
and call_context.qf_version is not None
|
|
273
|
-
and (context.qf_version_source or "unset") != "explicit"
|
|
274
329
|
)
|
|
275
330
|
if not is_retryable_404:
|
|
276
331
|
raise
|
|
@@ -306,6 +361,67 @@ class AppTools(ToolBase):
|
|
|
306
361
|
if not app_key:
|
|
307
362
|
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
308
363
|
|
|
364
|
+
def _probe_create_access(self, context: BackendRequestContext, app_key: str) -> bool:
|
|
365
|
+
try:
|
|
366
|
+
self.backend.request(
|
|
367
|
+
"GET",
|
|
368
|
+
context,
|
|
369
|
+
f"/app/{app_key}/form",
|
|
370
|
+
params={"type": 2, "beingApply": True},
|
|
371
|
+
)
|
|
372
|
+
return True
|
|
373
|
+
except QingflowApiError as exc:
|
|
374
|
+
if exc.backend_code in {40002, 40027}:
|
|
375
|
+
return False
|
|
376
|
+
raise
|
|
377
|
+
|
|
378
|
+
def _probe_list_type_access(self, context: BackendRequestContext, app_key: str, list_type: int) -> bool:
|
|
379
|
+
try:
|
|
380
|
+
self.backend.request(
|
|
381
|
+
"POST",
|
|
382
|
+
context,
|
|
383
|
+
f"/app/{app_key}/apply/filter",
|
|
384
|
+
json_body={"type": list_type, "pageNum": 1, "pageSize": 1},
|
|
385
|
+
)
|
|
386
|
+
return True
|
|
387
|
+
except QingflowApiError as exc:
|
|
388
|
+
if exc.backend_code in {40002, 40027}:
|
|
389
|
+
return False
|
|
390
|
+
raise
|
|
391
|
+
|
|
392
|
+
def _resolve_accessible_system_views(self, context: BackendRequestContext, app_key: str) -> list[JSONObject]:
|
|
393
|
+
items: list[JSONObject] = []
|
|
394
|
+
for view_id, list_type, name in SYSTEM_VIEW_DEFINITIONS:
|
|
395
|
+
if not self._probe_list_type_access(context, app_key, list_type):
|
|
396
|
+
continue
|
|
397
|
+
items.append({"view_id": view_id, "name": name, "kind": "system", "analysis_supported": True})
|
|
398
|
+
return items
|
|
399
|
+
|
|
400
|
+
def _resolve_accessible_custom_views(self, context: BackendRequestContext, app_key: str) -> list[JSONObject]:
|
|
401
|
+
try:
|
|
402
|
+
payload = self.backend.request("GET", context, f"/app/{app_key}/view/viewList")
|
|
403
|
+
except QingflowApiError as exc:
|
|
404
|
+
if exc.backend_code in {40002, 40027}:
|
|
405
|
+
return []
|
|
406
|
+
raise
|
|
407
|
+
|
|
408
|
+
items: list[JSONObject] = []
|
|
409
|
+
for item in _normalize_view_list(payload):
|
|
410
|
+
view_key = str(item.get("viewKey") or "").strip()
|
|
411
|
+
if not view_key:
|
|
412
|
+
continue
|
|
413
|
+
normalized: JSONObject = {
|
|
414
|
+
"view_id": f"custom:{view_key}",
|
|
415
|
+
"name": str(item.get("viewName") or view_key).strip() or view_key,
|
|
416
|
+
"kind": "custom",
|
|
417
|
+
}
|
|
418
|
+
view_type = str(item.get("viewType") or item.get("viewgraphType") or "").strip()
|
|
419
|
+
if view_type:
|
|
420
|
+
normalized["view_type"] = view_type
|
|
421
|
+
normalized["analysis_supported"] = _analysis_supported_for_view_type(view_type or None)
|
|
422
|
+
items.append(normalized)
|
|
423
|
+
return items
|
|
424
|
+
|
|
309
425
|
def _compact_base_info(self, result: dict[str, Any]) -> JSONObject:
|
|
310
426
|
publish_status = result.get("appPublishStatus")
|
|
311
427
|
auth = result.get("auth") if isinstance(result.get("auth"), dict) else {}
|
|
@@ -425,6 +541,88 @@ class AppTools(ToolBase):
|
|
|
425
541
|
}
|
|
426
542
|
return {key: value for key, value in compact.items() if value is not None}
|
|
427
543
|
|
|
544
|
+
def _extract_visible_apps(self, result: Any) -> tuple[list[JSONObject], str]:
|
|
545
|
+
apps: list[JSONObject] = []
|
|
546
|
+
seen: set[str] = set()
|
|
547
|
+
|
|
548
|
+
def walk(
|
|
549
|
+
node: Any,
|
|
550
|
+
*,
|
|
551
|
+
package_tag_id: int | None = None,
|
|
552
|
+
package_name: str | None = None,
|
|
553
|
+
group_id: int | None = None,
|
|
554
|
+
group_name: str | None = None,
|
|
555
|
+
) -> None:
|
|
556
|
+
if isinstance(node, list):
|
|
557
|
+
for item in node:
|
|
558
|
+
walk(
|
|
559
|
+
item,
|
|
560
|
+
package_tag_id=package_tag_id,
|
|
561
|
+
package_name=package_name,
|
|
562
|
+
group_id=group_id,
|
|
563
|
+
group_name=group_name,
|
|
564
|
+
)
|
|
565
|
+
return
|
|
566
|
+
if not isinstance(node, dict):
|
|
567
|
+
return
|
|
568
|
+
|
|
569
|
+
next_package_tag_id = _coerce_positive_int(node.get("tagId")) or package_tag_id
|
|
570
|
+
next_package_name = str(node.get("tagName") or "").strip() or package_name
|
|
571
|
+
next_group_id = _coerce_positive_int(node.get("groupId")) or group_id
|
|
572
|
+
next_group_name = str(node.get("groupName") or node.get("groupTitle") or "").strip() or group_name
|
|
573
|
+
|
|
574
|
+
normalized = self._normalize_visible_app(
|
|
575
|
+
node,
|
|
576
|
+
package_tag_id=next_package_tag_id,
|
|
577
|
+
package_name=next_package_name,
|
|
578
|
+
group_id=next_group_id,
|
|
579
|
+
group_name=next_group_name,
|
|
580
|
+
)
|
|
581
|
+
if normalized is not None:
|
|
582
|
+
app_key = str(normalized.get("app_key") or "").strip()
|
|
583
|
+
if app_key and app_key not in seen:
|
|
584
|
+
seen.add(app_key)
|
|
585
|
+
apps.append(normalized)
|
|
586
|
+
|
|
587
|
+
for value in node.values():
|
|
588
|
+
if isinstance(value, (list, dict)):
|
|
589
|
+
walk(
|
|
590
|
+
value,
|
|
591
|
+
package_tag_id=next_package_tag_id,
|
|
592
|
+
package_name=next_package_name,
|
|
593
|
+
group_id=next_group_id,
|
|
594
|
+
group_name=next_group_name,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
walk(result)
|
|
598
|
+
return apps, type(result).__name__
|
|
599
|
+
|
|
600
|
+
def _normalize_visible_app(
|
|
601
|
+
self,
|
|
602
|
+
item: dict[str, Any],
|
|
603
|
+
*,
|
|
604
|
+
package_tag_id: int | None,
|
|
605
|
+
package_name: str | None,
|
|
606
|
+
group_id: int | None,
|
|
607
|
+
group_name: str | None,
|
|
608
|
+
) -> JSONObject | None:
|
|
609
|
+
app_key = str(item.get("appKey") or item.get("app_key") or "").strip()
|
|
610
|
+
if not app_key:
|
|
611
|
+
return None
|
|
612
|
+
title = str(item.get("title") or item.get("formTitle") or item.get("appName") or item.get("name") or app_key).strip() or app_key
|
|
613
|
+
tag_ids = item.get("tagIds") if isinstance(item.get("tagIds"), list) else []
|
|
614
|
+
compact = {
|
|
615
|
+
"app_key": app_key,
|
|
616
|
+
"title": title,
|
|
617
|
+
"form_id": item.get("formId"),
|
|
618
|
+
"tag_id": package_tag_id,
|
|
619
|
+
"package_name": package_name,
|
|
620
|
+
"group_id": group_id,
|
|
621
|
+
"group_name": group_name,
|
|
622
|
+
"tag_ids": [value for value in (_coerce_positive_int(tag_id) for tag_id in tag_ids) if value is not None],
|
|
623
|
+
}
|
|
624
|
+
return {key: value for key, value in compact.items() if value not in (None, [], "", {})}
|
|
625
|
+
|
|
428
626
|
def _count_auth_members(self, auth_payload: Any, member_key: str) -> int:
|
|
429
627
|
if not isinstance(auth_payload, dict):
|
|
430
628
|
return 0
|
|
@@ -471,3 +669,34 @@ def _normalize_form_type(value: int | str) -> int:
|
|
|
471
669
|
if text in FORM_TYPE_ALIASES:
|
|
472
670
|
return FORM_TYPE_ALIASES[text]
|
|
473
671
|
raise_tool_error(QingflowApiError.config_error("form_type must be a positive integer or one of: default, form, schema, new, draft, edit"))
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _normalize_view_list(payload: Any) -> list[JSONObject]:
|
|
675
|
+
if not isinstance(payload, list):
|
|
676
|
+
return []
|
|
677
|
+
flattened: list[JSONObject] = []
|
|
678
|
+
for group in payload:
|
|
679
|
+
if not isinstance(group, dict):
|
|
680
|
+
continue
|
|
681
|
+
view_list = group.get("viewList")
|
|
682
|
+
if not isinstance(view_list, list):
|
|
683
|
+
continue
|
|
684
|
+
for item in view_list:
|
|
685
|
+
if isinstance(item, dict) and item.get("viewKey"):
|
|
686
|
+
flattened.append(item)
|
|
687
|
+
return flattened
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _analysis_supported_for_view_type(view_type: str | None) -> bool:
|
|
691
|
+
normalized = str(view_type or "").strip().lower()
|
|
692
|
+
if not normalized:
|
|
693
|
+
return True
|
|
694
|
+
return normalized not in {"boardview", "ganttview"}
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _coerce_positive_int(value: Any) -> int | None:
|
|
698
|
+
try:
|
|
699
|
+
number = int(value)
|
|
700
|
+
except (TypeError, ValueError):
|
|
701
|
+
return None
|
|
702
|
+
return number if number > 0 else None
|