@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
@@ -307,11 +307,20 @@ class AuthTools(ToolBase):
307
307
 
308
308
  def auth_whoami(self, *, profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
309
309
  try:
310
- session_profile, _, context = self._require_context(profile, require_workspace=False)
310
+ session_profile, backend_session, context = self._require_context(profile, require_workspace=False)
311
311
  except QingflowApiError as error:
312
312
  self._handle_error(profile, error)
313
313
  raise AssertionError("unreachable")
314
- return {
314
+ if self._should_refresh_identity_metadata(session_profile):
315
+ refreshed_profile = self._refresh_identity_metadata(
316
+ profile=profile,
317
+ session_profile=session_profile,
318
+ backend_session=backend_session,
319
+ context=context,
320
+ )
321
+ if refreshed_profile is not None:
322
+ session_profile = refreshed_profile
323
+ response = {
315
324
  "profile": session_profile.profile,
316
325
  "base_url": session_profile.base_url,
317
326
  "qf_version": session_profile.qf_version,
@@ -324,6 +333,14 @@ class AuthTools(ToolBase):
324
333
  "persisted": session_profile.persisted,
325
334
  "request_route": self._request_route_payload(context),
326
335
  }
336
+ member_info, member_warnings = self._workspace_member_info(
337
+ session_profile=session_profile,
338
+ backend_session=backend_session,
339
+ )
340
+ response.update(member_info)
341
+ if member_warnings:
342
+ response["warnings"] = member_warnings
343
+ return response
327
344
 
328
345
  def auth_logout(self, *, profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict[str, Any]:
329
346
  if not self.sessions.has_profile(profile):
@@ -520,6 +537,385 @@ class AuthTools(ToolBase):
520
537
  "qf_version_source": context.qf_version_source or ("context" if context.qf_version else "unknown"),
521
538
  }
522
539
 
540
+ def _should_refresh_identity_metadata(self, session_profile) -> bool: # type: ignore[no-untyped-def]
541
+ return (
542
+ session_profile.uid == 0
543
+ or session_profile.email is None
544
+ or session_profile.nick_name is None
545
+ or session_profile.selected_ws_name is None
546
+ )
547
+
548
+ def _refresh_identity_metadata(
549
+ self,
550
+ *,
551
+ profile: str,
552
+ session_profile, # type: ignore[no-untyped-def]
553
+ backend_session, # type: ignore[no-untyped-def]
554
+ context: BackendRequestContext,
555
+ ):
556
+ try:
557
+ user_info, _ = self._fetch_user_info(
558
+ session_profile.base_url,
559
+ backend_session.token,
560
+ session_profile.selected_ws_id,
561
+ qf_version=session_profile.qf_version,
562
+ qf_version_source=session_profile.qf_version_source,
563
+ )
564
+ except QingflowApiError:
565
+ return None
566
+
567
+ ws_name = session_profile.selected_ws_name
568
+ if session_profile.selected_ws_id is not None:
569
+ workspace = self._fetch_workspace_with_name_fallback(
570
+ session_profile.base_url,
571
+ backend_session.token,
572
+ session_profile.selected_ws_id,
573
+ qf_version=session_profile.qf_version,
574
+ qf_version_source=session_profile.qf_version_source,
575
+ )
576
+ if isinstance(workspace, dict):
577
+ ws_name = (
578
+ str(workspace.get("workspaceName") or workspace.get("wsName") or workspace.get("remark") or "").strip()
579
+ or ws_name
580
+ )
581
+ email = user_info["email"] if "email" in user_info else session_profile.email
582
+ nick_name = (
583
+ user_info.get("nickName")
584
+ or user_info.get("displayName")
585
+ or user_info.get("name")
586
+ or session_profile.nick_name
587
+ )
588
+
589
+ uid = user_info.get("uid")
590
+ refreshed = self.sessions.update_profile_metadata(
591
+ profile,
592
+ uid=int(uid) if uid is not None else session_profile.uid,
593
+ email=email,
594
+ nick_name=nick_name,
595
+ selected_ws_id=session_profile.selected_ws_id,
596
+ selected_ws_name=ws_name,
597
+ )
598
+ return refreshed
599
+
600
+ def _workspace_member_info(
601
+ self,
602
+ *,
603
+ session_profile, # type: ignore[no-untyped-def]
604
+ backend_session, # type: ignore[no-untyped-def]
605
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
606
+ default_payload = {
607
+ "departments": [],
608
+ "roles": [],
609
+ "permission_level": None,
610
+ }
611
+ ws_id = session_profile.selected_ws_id
612
+ if ws_id is None:
613
+ return default_payload, []
614
+
615
+ context = BackendRequestContext(
616
+ base_url=backend_session.base_url,
617
+ token=backend_session.token,
618
+ ws_id=ws_id,
619
+ qf_version=backend_session.qf_version,
620
+ qf_version_source=backend_session.qf_version_source,
621
+ )
622
+ permission_level = self._resolve_permission_level(
623
+ self._workspace_auth(context, ws_id=ws_id)
624
+ )
625
+ payload = dict(default_payload)
626
+ payload["permission_level"] = permission_level
627
+
628
+ member = self._lookup_current_member(
629
+ context=context,
630
+ uid=session_profile.uid,
631
+ email=session_profile.email,
632
+ nick_name=session_profile.nick_name,
633
+ )
634
+ if member is None:
635
+ return payload, [
636
+ {
637
+ "code": "CURRENT_MEMBER_PROFILE_UNAVAILABLE",
638
+ "message": (
639
+ "auth_whoami could not resolve current member departments and roles "
640
+ f"in workspace {ws_id}."
641
+ ),
642
+ }
643
+ ]
644
+
645
+ payload["departments"] = self._compact_departments(member)
646
+ payload["roles"] = self._compact_roles(member)
647
+ return payload, []
648
+
649
+ def _workspace_auth(self, context: BackendRequestContext, *, ws_id: int) -> int | None:
650
+ workspace = self._fetch_workspace_auth_from_detail(context, ws_id=ws_id)
651
+ if workspace is not None:
652
+ return workspace
653
+ return self._fetch_workspace_auth_from_list(context, ws_id=ws_id)
654
+
655
+ def _fetch_workspace_auth_from_detail(self, context: BackendRequestContext, *, ws_id: int) -> int | None:
656
+ try:
657
+ workspace = self.backend.request("GET", context, f"/user/workspace/{ws_id}")
658
+ except QingflowApiError:
659
+ return None
660
+ if not isinstance(workspace, dict):
661
+ return None
662
+ return self._coerce_auth_value(workspace.get("auth"))
663
+
664
+ def _fetch_workspace_auth_from_list(self, context: BackendRequestContext, *, ws_id: int) -> int | None:
665
+ try:
666
+ payload = self.backend.request(
667
+ "POST",
668
+ context,
669
+ "/user/workspaceList/pageQuery",
670
+ json_body={"pageNum": 1, "pageSize": 100, "authList": [0, 1, 2, 3]},
671
+ )
672
+ except QingflowApiError:
673
+ return None
674
+ workspaces = payload.get("list") if isinstance(payload, dict) else []
675
+ if not isinstance(workspaces, list):
676
+ return None
677
+ for item in workspaces:
678
+ if not isinstance(item, dict) or item.get("wsId") != ws_id:
679
+ continue
680
+ return self._coerce_auth_value(item.get("auth"))
681
+ return None
682
+
683
+ def _lookup_current_member(
684
+ self,
685
+ *,
686
+ context: BackendRequestContext,
687
+ uid: int | None,
688
+ email: str | None,
689
+ nick_name: str | None,
690
+ ) -> dict[str, Any] | None:
691
+ candidates: list[dict[str, Any]] = []
692
+ for keyword in (email, nick_name):
693
+ member = self._search_member_once(context, uid=uid, keyword=keyword)
694
+ if member is not None:
695
+ return member
696
+ if keyword:
697
+ candidates.extend(self._search_member_items(context, keyword=keyword))
698
+ if uid is not None and uid > 0:
699
+ for item in candidates:
700
+ if self._same_member(item, uid=uid):
701
+ return item
702
+ return self._search_member_once(context, uid=uid, keyword=None)
703
+ return None
704
+
705
+ def _search_member_once(
706
+ self,
707
+ context: BackendRequestContext,
708
+ *,
709
+ uid: int | None,
710
+ keyword: str | None,
711
+ ) -> dict[str, Any] | None:
712
+ for item in self._search_member_items(context, keyword=keyword):
713
+ if self._same_member(item, uid=uid):
714
+ return item
715
+ return None
716
+
717
+ def _search_member_items(self, context: BackendRequestContext, *, keyword: str | None) -> list[dict[str, Any]]:
718
+ params: dict[str, Any] = {"pageNum": 1, "pageSize": 100, "containDisable": True}
719
+ normalized_keyword = str(keyword or "").strip()
720
+ if normalized_keyword:
721
+ params["keyword"] = normalized_keyword
722
+ try:
723
+ payload = self.backend.request("GET", context, "/contact", params=params)
724
+ except QingflowApiError:
725
+ return []
726
+ items = self._extract_items(payload)
727
+ return [item for item in items if isinstance(item, dict)]
728
+
729
+ def _same_member(self, item: dict[str, Any], *, uid: int | None) -> bool:
730
+ if uid is None or uid <= 0:
731
+ return False
732
+ for key in ("uid", "id", "userId"):
733
+ value = item.get(key)
734
+ if value is None:
735
+ continue
736
+ coerced = self._coerce_int(value)
737
+ if coerced is not None and coerced == uid:
738
+ return True
739
+ if str(value).strip() == str(uid):
740
+ return True
741
+ return False
742
+
743
+ def _compact_departments(self, member: dict[str, Any]) -> list[dict[str, Any]]:
744
+ items: list[dict[str, Any]] = []
745
+ seen: set[tuple[int | None, str | None]] = set()
746
+ for depart in self._walk_nested_items(member.get("departs")):
747
+ if not isinstance(depart, dict):
748
+ continue
749
+ dept_id = self._coerce_int(
750
+ depart.get("deptId", depart.get("departId", depart.get("id")))
751
+ )
752
+ dept_name = self._normalize_text(
753
+ depart.get("deptName", depart.get("departName", depart.get("name")))
754
+ )
755
+ key = (dept_id, dept_name)
756
+ if key in seen or (dept_id is None and dept_name is None):
757
+ continue
758
+ seen.add(key)
759
+ item = {"dept_id": dept_id, "dept_name": dept_name}
760
+ items.append({k: v for k, v in item.items() if v is not None})
761
+ return items
762
+
763
+ def _compact_roles(self, member: dict[str, Any]) -> list[dict[str, Any]]:
764
+ items: list[dict[str, Any]] = []
765
+ seen: set[tuple[int | None, str | None]] = set()
766
+ for role in self._walk_nested_items(member.get("roles")):
767
+ if not isinstance(role, dict):
768
+ continue
769
+ role_id = self._coerce_int(role.get("roleId", role.get("id")))
770
+ role_name = self._normalize_text(role.get("roleName", role.get("name")))
771
+ key = (role_id, role_name)
772
+ if key in seen or (role_id is None and role_name is None):
773
+ continue
774
+ seen.add(key)
775
+ item = {"role_id": role_id, "role_name": role_name}
776
+ items.append({k: v for k, v in item.items() if v is not None})
777
+ return items
778
+
779
+ def _resolve_permission_level(self, auth_code: int | None) -> str | None:
780
+ mapping = {
781
+ 2: "超级管理",
782
+ 1: "系统管理员",
783
+ 3: "子管理员",
784
+ 0: "基本成员",
785
+ }
786
+ return mapping.get(auth_code)
787
+
788
+ def _coerce_auth_value(self, value: Any) -> int | None:
789
+ coerced = self._coerce_int(value)
790
+ if coerced is not None:
791
+ return coerced
792
+ normalized = self._normalize_text(value)
793
+ if normalized is None:
794
+ return None
795
+ lowered = normalized.lower()
796
+ if lowered in {"creator", "workspaccreator", "workspacecreator"}:
797
+ return 2
798
+ if lowered in {"admin", "administrator"}:
799
+ return 1
800
+ if lowered in {"subadmin", "dataadmin"}:
801
+ return 3
802
+ if lowered in {"member", "visitor", "normal"}:
803
+ return 0
804
+ return None
805
+
806
+ def _extract_items(self, payload: Any) -> list[Any]:
807
+ if isinstance(payload, list):
808
+ return payload
809
+ if not isinstance(payload, dict):
810
+ return []
811
+ for key in ("list", "items", "rows", "result"):
812
+ value = payload.get(key)
813
+ if isinstance(value, list):
814
+ return value
815
+ for key in ("data", "page"):
816
+ nested = payload.get(key)
817
+ if isinstance(nested, list):
818
+ return nested
819
+ if isinstance(nested, dict):
820
+ for nested_key in ("list", "items", "rows", "result"):
821
+ value = nested.get(nested_key)
822
+ if isinstance(value, list):
823
+ return value
824
+ return []
825
+
826
+ def _walk_nested_items(self, value: Any) -> list[Any]:
827
+ if isinstance(value, list):
828
+ items: list[Any] = []
829
+ for item in value:
830
+ items.extend(self._walk_nested_items(item))
831
+ return items
832
+ return [value]
833
+
834
+ def _coerce_int(self, value: Any) -> int | None:
835
+ if isinstance(value, bool) or value is None:
836
+ return None
837
+ if isinstance(value, int):
838
+ return value
839
+ try:
840
+ return int(str(value).strip())
841
+ except (TypeError, ValueError):
842
+ return None
843
+
844
+ def _normalize_text(self, value: Any) -> str | None:
845
+ if value is None:
846
+ return None
847
+ text = str(value).strip()
848
+ return text or None
849
+
850
+ def _fetch_workspace_with_name_fallback(
851
+ self,
852
+ base_url: str,
853
+ token: str,
854
+ ws_id: int,
855
+ *,
856
+ qf_version: str | None,
857
+ qf_version_source: str | None,
858
+ ) -> dict[str, Any] | None:
859
+ try:
860
+ workspace = self._fetch_workspace(
861
+ base_url,
862
+ token,
863
+ ws_id,
864
+ qf_version=qf_version,
865
+ qf_version_source=qf_version_source,
866
+ )
867
+ except QingflowApiError:
868
+ workspace = None
869
+ if isinstance(workspace, dict):
870
+ workspace_name = str(workspace.get("workspaceName") or workspace.get("wsName") or "").strip()
871
+ if workspace_name:
872
+ return workspace
873
+ try:
874
+ fallback = self._fetch_workspace_from_list(
875
+ base_url,
876
+ token,
877
+ ws_id,
878
+ qf_version=qf_version,
879
+ qf_version_source=qf_version_source,
880
+ )
881
+ except QingflowApiError:
882
+ fallback = None
883
+ return fallback or workspace
884
+
885
+ def _fetch_workspace_from_list(
886
+ self,
887
+ base_url: str,
888
+ token: str,
889
+ ws_id: int,
890
+ *,
891
+ qf_version: str | None,
892
+ qf_version_source: str | None,
893
+ ) -> dict[str, Any] | None:
894
+ payload = self.backend.request(
895
+ "POST",
896
+ BackendRequestContext(
897
+ base_url=base_url,
898
+ token=token,
899
+ ws_id=ws_id,
900
+ qf_version=qf_version,
901
+ qf_version_source=qf_version_source,
902
+ ),
903
+ "/user/workspaceList/pageQuery",
904
+ json_body={"pageNum": 1, "pageSize": 100, "authList": [0, 1, 2]},
905
+ )
906
+ workspaces = payload.get("list") if isinstance(payload, dict) else []
907
+ if not isinstance(workspaces, list):
908
+ return None
909
+ found = next(
910
+ (
911
+ item
912
+ for item in workspaces
913
+ if isinstance(item, dict) and item.get("wsId") == ws_id
914
+ ),
915
+ None,
916
+ )
917
+ return found if isinstance(found, dict) else None
918
+
523
919
  def _fetch_public_key(self, base_url: str, *, qf_version: str | None) -> str | None:
524
920
  # Endpoints to try (order matters, lowercase 'pubkey' is for Public Cloud)
525
921
  endpoints = ["/user/pubkey", "/api/user/pubkey", "/user/publicKey", "/api/user/publicKey"]