@josephyan/qingflow-app-builder-mcp 0.2.0-beta.3 → 0.2.0-beta.5

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 CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.3
6
+ npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.5
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.3 qingflow-app-builder-mcp
12
+ npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.5 qingflow-app-builder-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-builder-mcp",
3
- "version": "0.2.0-beta.3",
3
+ "version": "0.2.0-beta.5",
4
4
  "description": "Builder MCP for Qingflow app/package/system design and staged solution workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.2.0b3"
7
+ version = "0.2.0b5"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,4 +2,4 @@ from __future__ import annotations
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0b3"
5
+ __version__ = "0.2.0b5"
@@ -221,8 +221,14 @@ class AiBuilderFacade:
221
221
  if app_key:
222
222
  try:
223
223
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True)
224
- except RuntimeError as exc:
225
- return _failed("APP_NOT_FOUND", f"failed to resolve app_key '{app_key}'", details={"error": str(exc)})
224
+ except (QingflowApiError, RuntimeError) as exc:
225
+ api_error = _coerce_api_error(exc)
226
+ return _failed_from_api_error(
227
+ "APP_NOT_FOUND" if api_error.http_status == 404 else "APP_RESOLVE_FAILED",
228
+ api_error,
229
+ details={"app_key": app_key},
230
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
231
+ )
226
232
  result = base.get("result") if isinstance(base.get("result"), dict) else {}
227
233
  return {
228
234
  "status": "success",
@@ -762,16 +768,23 @@ class AiBuilderFacade:
762
768
  app_name=str(resolved["app_name"]),
763
769
  tag_ids=_coerce_int_list(resolved.get("tag_ids")),
764
770
  )
765
- schema = self.apps.app_get_form_schema(
766
- profile=profile,
767
- app_key=target.app_key,
768
- form_type=1,
769
- being_draft=True,
770
- being_apply=None,
771
- audit_node_id=None,
772
- include_raw=True,
773
- )
774
- schema_result = schema.get("result") if isinstance(schema.get("result"), dict) else {}
771
+ schema_readback_delayed = False
772
+ try:
773
+ schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=target.app_key)
774
+ except (QingflowApiError, RuntimeError) as error:
775
+ api_error = _coerce_api_error(error)
776
+ if not bool(resolved.get("created")) or api_error.http_status != 404:
777
+ return _failed_from_api_error(
778
+ "SCHEMA_READBACK_FAILED",
779
+ api_error,
780
+ normalized_args=normalized_args,
781
+ allowed_values={"field_types": [item.value for item in PublicFieldType]},
782
+ details={"app_key": target.app_key},
783
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": target.app_key}},
784
+ )
785
+ schema_result = _empty_schema_result(target.app_name)
786
+ _schema_source = "synthetic_new_app"
787
+ schema_readback_delayed = True
775
788
  parsed = _parse_schema(schema_result)
776
789
  current_fields = parsed["fields"]
777
790
  layout = parsed["layout"]
@@ -879,13 +892,8 @@ class AiBuilderFacade:
879
892
  },
880
893
  suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
881
894
  )
882
- verified = self.app_read(profile=profile, app_key=target.app_key, include_raw=False)
883
- verified_field_names = {field["name"] for field in verified["schema"]["fields"]}
884
- tag_ids_after = _coerce_int_list((self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}).get("tagIds"))
885
- verification_ok = all(name in verified_field_names for name in added + updated) and all(name not in verified_field_names for name in removed)
886
- package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
887
895
  response = {
888
- "status": "success" if verification_ok and (package_attached is not False) else "partial_success",
896
+ "status": "success",
889
897
  "error_code": None,
890
898
  "recoverable": False,
891
899
  "message": "applied schema patch",
@@ -894,21 +902,11 @@ class AiBuilderFacade:
894
902
  "allowed_values": {"field_types": [item.value for item in PublicFieldType]},
895
903
  "details": {},
896
904
  "request_id": None,
897
- "suggested_next_call": None
898
- if package_attached is not False
899
- else {
900
- "tool_name": "package_attach_app",
901
- "arguments": {
902
- "profile": profile,
903
- "tag_id": package_tag_id,
904
- "app_key": target.app_key,
905
- "app_title": app_name or target.app_name,
906
- },
907
- },
905
+ "suggested_next_call": None,
908
906
  "noop": False,
909
907
  "verification": {
910
- "fields_verified": verification_ok,
911
- "package_attached": package_attached,
908
+ "fields_verified": False,
909
+ "package_attached": None,
912
910
  },
913
911
  "app_key": target.app_key,
914
912
  "created": bool(resolved.get("created")),
@@ -917,11 +915,71 @@ class AiBuilderFacade:
917
915
  "updated": updated,
918
916
  "removed": removed,
919
917
  },
920
- "verified": verification_ok,
921
- "tag_ids_after": tag_ids_after,
922
- "package_attached": package_attached,
918
+ "verified": False,
919
+ "tag_ids_after": [],
920
+ "package_attached": None,
923
921
  }
924
- return self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
922
+ if schema_readback_delayed:
923
+ response["verification"]["schema_readback_delayed"] = True
924
+ response = self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
925
+ verification_ok = False
926
+ tag_ids_after: list[int] = []
927
+ package_attached: bool | None = None
928
+ verification_error: QingflowApiError | None = None
929
+ try:
930
+ verified = self.app_read(profile=profile, app_key=target.app_key, include_raw=False)
931
+ verified_field_names = {field["name"] for field in verified["schema"]["fields"]}
932
+ verification_ok = all(name in verified_field_names for name in added + updated) and all(name not in verified_field_names for name in removed)
933
+ except (QingflowApiError, RuntimeError) as error:
934
+ verification_error = _coerce_api_error(error)
935
+ verification_ok = False
936
+ try:
937
+ base_info = self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}
938
+ tag_ids_after = _coerce_int_list(base_info.get("tagIds"))
939
+ package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
940
+ except (QingflowApiError, RuntimeError) as error:
941
+ base_error = _coerce_api_error(error)
942
+ if verification_error is None:
943
+ verification_error = base_error
944
+ tag_ids_after = []
945
+ package_attached = None if package_tag_id is None else False
946
+ response["verification"]["fields_verified"] = verification_ok
947
+ response["verification"]["package_attached"] = package_attached
948
+ response["verified"] = verification_ok
949
+ response["tag_ids_after"] = tag_ids_after
950
+ response["package_attached"] = package_attached
951
+ if package_attached is False:
952
+ response["suggested_next_call"] = {
953
+ "tool_name": "package_attach_app",
954
+ "arguments": {
955
+ "profile": profile,
956
+ "tag_id": package_tag_id,
957
+ "app_key": target.app_key,
958
+ "app_title": app_name or target.app_name,
959
+ },
960
+ }
961
+ publish_failed = bool(response.get("publish_requested")) and not bool(response.get("published"))
962
+ if verification_ok and package_attached is not False and not publish_failed:
963
+ response["status"] = "success"
964
+ else:
965
+ response["status"] = "partial_success"
966
+ if verification_error is not None:
967
+ response["recoverable"] = True
968
+ response["error_code"] = response.get("error_code") or (
969
+ "READBACK_PENDING" if verification_error.http_status == 404 else "READBACK_FAILED"
970
+ )
971
+ response["message"] = f"{response.get('message') or 'apply succeeded'}; readback pending"
972
+ response["request_id"] = response.get("request_id") or verification_error.request_id
973
+ details = response.get("details")
974
+ if not isinstance(details, dict):
975
+ details = {}
976
+ response["details"] = details
977
+ details["verification_error"] = {
978
+ "message": verification_error.message,
979
+ "http_status": verification_error.http_status,
980
+ "backend_code": verification_error.backend_code,
981
+ }
982
+ return response
925
983
 
926
984
  def app_layout_apply(
927
985
  self,
@@ -938,16 +996,7 @@ class AiBuilderFacade:
938
996
  "sections": [section.model_dump(mode="json") for section in sections],
939
997
  "publish": publish,
940
998
  }
941
- schema = self.apps.app_get_form_schema(
942
- profile=profile,
943
- app_key=app_key,
944
- form_type=1,
945
- being_draft=True,
946
- being_apply=None,
947
- audit_node_id=None,
948
- include_raw=True,
949
- )
950
- schema_result = schema.get("result") if isinstance(schema.get("result"), dict) else {}
999
+ schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
951
1000
  parsed = _parse_schema(schema_result)
952
1001
  current_fields = parsed["fields"]
953
1002
  fields_by_name = {field["name"]: field for field in current_fields}
@@ -1130,15 +1179,7 @@ class AiBuilderFacade:
1130
1179
  suggested_next_call={"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}},
1131
1180
  )
1132
1181
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
1133
- schema = self.apps.app_get_form_schema(
1134
- profile=profile,
1135
- app_key=app_key,
1136
- form_type=1,
1137
- being_draft=True,
1138
- being_apply=None,
1139
- audit_node_id=None,
1140
- include_raw=True,
1141
- ).get("result") or {}
1182
+ schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
1142
1183
  entity = _entity_spec_from_app(base_info=base, schema=schema, views=None)
1143
1184
  workflow_spec = _build_public_workflow_spec(nodes=nodes, transitions=transitions)
1144
1185
  if workflow_spec.get("status") == "failed":
@@ -1246,15 +1287,7 @@ class AiBuilderFacade:
1246
1287
  }
1247
1288
  return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
1248
1289
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
1249
- schema = self.apps.app_get_form_schema(
1250
- profile=profile,
1251
- app_key=app_key,
1252
- form_type=1,
1253
- being_draft=True,
1254
- being_apply=None,
1255
- audit_node_id=None,
1256
- include_raw=True,
1257
- ).get("result") or {}
1290
+ schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
1258
1291
  existing_views = self.views.view_list_flat(profile=profile, app_key=app_key).get("result") or []
1259
1292
  existing_by_name = {}
1260
1293
  for view in existing_views if isinstance(existing_views, list) else []:
@@ -1527,27 +1560,49 @@ class AiBuilderFacade:
1527
1560
 
1528
1561
  def _load_app_state(self, *, profile: str, app_key: str) -> dict[str, Any]:
1529
1562
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True)
1530
- schema = self.apps.app_get_form_schema(
1531
- profile=profile,
1532
- app_key=app_key,
1533
- form_type=1,
1534
- being_draft=True,
1535
- being_apply=None,
1536
- audit_node_id=None,
1537
- include_raw=True,
1538
- )
1563
+ schema_result, schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
1539
1564
  views = self.views.view_list_flat(profile=profile, app_key=app_key)
1540
1565
  workflow = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key)
1541
1566
  base_result = base.get("result") if isinstance(base.get("result"), dict) else {}
1542
- schema_result = schema.get("result") if isinstance(schema.get("result"), dict) else {}
1543
1567
  return {
1544
1568
  "base": base_result,
1545
1569
  "schema": schema_result,
1546
1570
  "parsed": _parse_schema(schema_result),
1547
1571
  "views": views.get("result"),
1548
1572
  "workflow": workflow.get("result"),
1573
+ "schema_source": schema_source,
1549
1574
  }
1550
1575
 
1576
+ def _read_schema_with_fallback(self, *, profile: str, app_key: str) -> tuple[dict[str, Any], str]:
1577
+ attempts = (
1578
+ ("draft", True),
1579
+ ("current", None),
1580
+ ("published", False),
1581
+ )
1582
+ last_error: Exception | None = None
1583
+ for label, being_draft in attempts:
1584
+ try:
1585
+ schema = self.apps.app_get_form_schema(
1586
+ profile=profile,
1587
+ app_key=app_key,
1588
+ form_type=1,
1589
+ being_draft=being_draft,
1590
+ being_apply=None,
1591
+ audit_node_id=None,
1592
+ include_raw=True,
1593
+ )
1594
+ result = schema.get("result")
1595
+ return (result if isinstance(result, dict) else {}), label
1596
+ except (QingflowApiError, RuntimeError) as error:
1597
+ api_error = _coerce_api_error(error)
1598
+ last_error = error
1599
+ if api_error.http_status == 404:
1600
+ continue
1601
+ raise
1602
+ if last_error is not None:
1603
+ raise last_error
1604
+ return {}, "unknown"
1605
+
1551
1606
  def _preview_target_app(
1552
1607
  self,
1553
1608
  *,
@@ -1635,7 +1690,28 @@ class AiBuilderFacade:
1635
1690
  new_app_key = str(result.get("appKey") or (result.get("appKeys")[0] if isinstance(result.get("appKeys"), list) and result.get("appKeys") else ""))
1636
1691
  if not new_app_key:
1637
1692
  return _failed("APP_CREATE_FAILED", "failed to create app shell", details={"result": result}, suggested_next_call=None)
1638
- base = self.apps.app_get_base(profile=profile, app_key=new_app_key, include_raw=True).get("result") or {}
1693
+ try:
1694
+ base = self.apps.app_get_base(profile=profile, app_key=new_app_key, include_raw=True).get("result") or {}
1695
+ except (QingflowApiError, RuntimeError) as error:
1696
+ api_error = _coerce_api_error(error)
1697
+ if api_error.http_status != 404:
1698
+ return _failed_from_api_error(
1699
+ "APP_CREATE_READBACK_FAILED",
1700
+ api_error,
1701
+ details={"app_key": new_app_key, "app_name": app_name, "package_tag_id": package_tag_id},
1702
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": new_app_key}},
1703
+ )
1704
+ return {
1705
+ "status": "success",
1706
+ "error_code": None,
1707
+ "recoverable": False,
1708
+ "message": "created app; base readback pending",
1709
+ "suggested_next_call": None,
1710
+ "app_key": new_app_key,
1711
+ "app_name": app_name or "未命名应用",
1712
+ "tag_ids": [],
1713
+ "created": True,
1714
+ }
1639
1715
  return {
1640
1716
  "status": "success",
1641
1717
  "error_code": None,
@@ -1807,6 +1883,15 @@ def _coerce_int_list(values: Any) -> list[int]:
1807
1883
  return result
1808
1884
 
1809
1885
 
1886
+ def _empty_schema_result(title: str) -> dict[str, Any]:
1887
+ return {
1888
+ "formTitle": title,
1889
+ "editVersionNo": 1,
1890
+ "formQues": [],
1891
+ "questionRelations": [],
1892
+ }
1893
+
1894
+
1810
1895
  def _slugify(text: str, *, default: str) -> str:
1811
1896
  normalized = "".join(ch.lower() if ch.isalnum() else "_" for ch in str(text or ""))
1812
1897
  collapsed = "_".join(part for part in normalized.split("_") if part)
@@ -237,7 +237,7 @@ class AppTools(ToolBase):
237
237
 
238
238
  def runner(session_profile, context):
239
239
  attempted_contexts = [context]
240
- if context.qf_version is not None and (context.qf_version_source or "unset") != "explicit":
240
+ if context.qf_version is not None:
241
241
  attempted_contexts.append(
242
242
  BackendRequestContext(
243
243
  base_url=context.base_url,
@@ -270,7 +270,6 @@ class AppTools(ToolBase):
270
270
  is_retryable_404 = (
271
271
  error.http_status == 404
272
272
  and call_context.qf_version is not None
273
- and (context.qf_version_source or "unset") != "explicit"
274
273
  )
275
274
  if not is_retryable_404:
276
275
  raise