@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.
Files changed (39) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +3 -1
  4. package/skills/qingflow-app-builder/SKILL.md +154 -22
  5. package/skills/qingflow-app-builder/references/create-app.md +51 -21
  6. package/skills/qingflow-app-builder/references/environments.md +1 -1
  7. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
  8. package/skills/qingflow-app-builder/references/gotchas.md +28 -1
  9. package/skills/qingflow-app-builder/references/solution-playbooks.md +14 -12
  10. package/skills/qingflow-app-builder/references/tool-selection.md +45 -17
  11. package/skills/qingflow-app-builder/references/update-flow.md +112 -25
  12. package/skills/qingflow-app-builder/references/update-layout.md +11 -24
  13. package/skills/qingflow-app-builder/references/update-schema.md +1 -23
  14. package/skills/qingflow-app-builder/references/update-views.md +87 -21
  15. package/src/qingflow_mcp/__init__.py +1 -1
  16. package/src/qingflow_mcp/backend_client.py +189 -0
  17. package/src/qingflow_mcp/builder_facade/models.py +584 -1
  18. package/src/qingflow_mcp/builder_facade/service.py +4698 -262
  19. package/src/qingflow_mcp/config.py +39 -0
  20. package/src/qingflow_mcp/import_store.py +121 -0
  21. package/src/qingflow_mcp/list_type_labels.py +24 -0
  22. package/src/qingflow_mcp/server.py +131 -16
  23. package/src/qingflow_mcp/server_app_builder.py +132 -72
  24. package/src/qingflow_mcp/server_app_user.py +143 -187
  25. package/src/qingflow_mcp/solution/compiler/form_compiler.py +14 -4
  26. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +41 -2
  27. package/src/qingflow_mcp/solution/executor.py +44 -7
  28. package/src/qingflow_mcp/tools/ai_builder_tools.py +1567 -144
  29. package/src/qingflow_mcp/tools/app_tools.py +243 -14
  30. package/src/qingflow_mcp/tools/approval_tools.py +411 -76
  31. package/src/qingflow_mcp/tools/directory_tools.py +203 -31
  32. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  33. package/src/qingflow_mcp/tools/file_tools.py +1 -0
  34. package/src/qingflow_mcp/tools/import_tools.py +1164 -0
  35. package/src/qingflow_mcp/tools/portal_tools.py +31 -0
  36. package/src/qingflow_mcp/tools/record_tools.py +4943 -1025
  37. package/src/qingflow_mcp/tools/task_context_tools.py +1335 -0
  38. package/src/qingflow_mcp/tools/task_tools.py +376 -225
  39. 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
- """Get all apps with full hierarchy from tag/apps endpoint."""
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
- return {
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": result,
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
- apps.append({
108
- "app_key": item.get("appKey"),
109
- "title": item.get("title") or item.get("formTitle"),
110
- "form_id": item.get("formId"),
111
- "tag_id": item.get("tagId"),
112
- "group_id": item.get("groupId"),
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 and (context.qf_version_source or "unset") != "explicit":
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