@josephyan/qingflow-app-user-mcp 0.2.0-beta.981 → 0.2.0-beta.983

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-user-mcp@0.2.0-beta.981
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.983
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.981 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.983 qingflow-app-user-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-user-mcp",
3
- "version": "0.2.0-beta.981",
3
+ "version": "0.2.0-beta.983",
4
4
  "description": "Operational end-user MCP for Qingflow records, tasks, comments, and directory 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.0b981"
7
+ version = "0.2.0b983"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  __all__ = ["__version__"]
7
7
 
8
- _FALLBACK_VERSION = "0.2.0b981"
8
+ _FALLBACK_VERSION = "0.2.0b983"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -1536,8 +1536,8 @@ class PortalApplyRequest(StrictModel):
1536
1536
  raise ValueError("package_tag_id is required when dash_key is empty")
1537
1537
  if not self.dash_key and not self.dash_name:
1538
1538
  raise ValueError("dash_name is required when creating a portal")
1539
- if not self.sections:
1540
- raise ValueError("portal apply requires a non-empty sections list")
1539
+ if not self.dash_key and not self.sections:
1540
+ raise ValueError("portal apply requires a non-empty sections list when creating a portal")
1541
1541
  if self.visibility is not None and self.auth is not None:
1542
1542
  raise ValueError("visibility and auth cannot be provided together")
1543
1543
  return self
@@ -1413,6 +1413,8 @@ class AiBuilderFacade:
1413
1413
  issues: list[dict[str, Any]] = []
1414
1414
  resolved: list[dict[str, Any]] = []
1415
1415
  seen_ids: set[int] = set()
1416
+ if not dept_ids and not dept_names:
1417
+ return {"department_entries": resolved, "issues": issues}
1416
1418
  listed = self.directory.directory_list_all_departments(
1417
1419
  profile=profile,
1418
1420
  parent_dept_id=None,
@@ -2784,6 +2786,19 @@ class AiBuilderFacade:
2784
2786
  "can_copy_app": _coerce_optional_bool(base.get("copyAppStatus")),
2785
2787
  }
2786
2788
 
2789
+ def _derive_can_edit_app_base(self, *, profile: str, permission_summary: JSONObject) -> bool:
2790
+ if permission_summary.get("can_edit_app") is not True:
2791
+ return False
2792
+ tag_ids = _coerce_int_list(permission_summary.get("tag_ids"))
2793
+ for tag_id in tag_ids:
2794
+ try:
2795
+ package_permission = self._read_package_permission_summary(profile=profile, tag_id=tag_id)
2796
+ except (QingflowApiError, RuntimeError):
2797
+ return False
2798
+ if package_permission.get("can_edit_tag") is not True:
2799
+ return False
2800
+ return True
2801
+
2787
2802
  def _read_portal_permission_summary(self, *, dash_key: str, portal_result: dict[str, Any]) -> JSONObject:
2788
2803
  tag_ids = _coerce_int_list(portal_result.get("tagIds"))
2789
2804
  if not tag_ids:
@@ -2997,7 +3012,7 @@ class AiBuilderFacade:
2997
3012
 
2998
3013
  def app_read_summary(self, *, profile: str, app_key: str) -> JSONObject:
2999
3014
  try:
3000
- state = self._load_base_schema_state(profile=profile, app_key=app_key)
3015
+ base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True)
3001
3016
  except (QingflowApiError, RuntimeError) as error:
3002
3017
  api_error = _coerce_api_error(error)
3003
3018
  return _failed_from_api_error(
@@ -3007,26 +3022,55 @@ class AiBuilderFacade:
3007
3022
  details={"app_key": app_key},
3008
3023
  suggested_next_call={"tool_name": "app_resolve", "arguments": {"profile": profile, "app_key": app_key}},
3009
3024
  )
3010
- views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
3011
- workflow, workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
3012
- parsed = state["parsed"]
3025
+ base_result = base.get("result") if isinstance(base.get("result"), dict) else {}
3026
+ schema_unavailable = False
3027
+ try:
3028
+ schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
3029
+ parsed = _parse_schema(schema_result)
3030
+ except (QingflowApiError, RuntimeError) as error:
3031
+ api_error = _coerce_api_error(error)
3032
+ if api_error.http_status == 404 or _is_permission_restricted_api_error(api_error):
3033
+ schema_unavailable = True
3034
+ parsed = {"fields": [], "layout": {"sections": []}}
3035
+ else:
3036
+ return _failed_from_api_error(
3037
+ "APP_READ_FAILED",
3038
+ api_error,
3039
+ normalized_args={"app_key": app_key},
3040
+ details={"app_key": app_key},
3041
+ suggested_next_call={"tool_name": "app_resolve", "arguments": {"profile": profile, "app_key": app_key}},
3042
+ )
3043
+ views, views_unavailable = self._load_views_result(
3044
+ profile=profile,
3045
+ app_key=app_key,
3046
+ tolerate_404=True,
3047
+ tolerate_permission_restricted=True,
3048
+ )
3049
+ workflow, workflow_unavailable = self._load_workflow_result(
3050
+ profile=profile,
3051
+ app_key=app_key,
3052
+ tolerate_404=True,
3053
+ tolerate_permission_restricted=True,
3054
+ )
3013
3055
  verification_hints = _build_verification_hints(
3014
- tag_ids=_coerce_int_list(state["base"].get("tagIds")),
3056
+ tag_ids=_coerce_int_list(base_result.get("tagIds")),
3015
3057
  fields=parsed["fields"],
3016
3058
  layout=parsed["layout"],
3017
3059
  views=_summarize_views(views),
3018
3060
  )
3061
+ if schema_unavailable:
3062
+ verification_hints.append("schema_read_unavailable")
3019
3063
  if views_unavailable:
3020
3064
  verification_hints.append("views_read_unavailable")
3021
3065
  if workflow_unavailable:
3022
3066
  verification_hints.append("workflow_read_unavailable")
3023
3067
  response = AppReadSummaryResponse(
3024
3068
  app_key=app_key,
3025
- title=state["base"].get("formTitle"),
3026
- app_icon=str(state["base"].get("appIcon") or "").strip() or None,
3027
- visibility=_public_visibility_from_member_auth(state["base"].get("auth")),
3028
- tag_ids=_coerce_int_list(state["base"].get("tagIds")),
3029
- publish_status=state["base"].get("appPublishStatus"),
3069
+ title=base_result.get("formTitle"),
3070
+ app_icon=str(base_result.get("appIcon") or "").strip() or None,
3071
+ visibility=_public_visibility_from_member_auth(base_result.get("auth")),
3072
+ tag_ids=_coerce_int_list(base_result.get("tagIds")),
3073
+ publish_status=base_result.get("appPublishStatus"),
3030
3074
  field_count=len(parsed["fields"]),
3031
3075
  layout_section_count=len(parsed["layout"].get("sections", [])),
3032
3076
  view_count=len(_summarize_views(views)),
@@ -3048,10 +3092,11 @@ class AiBuilderFacade:
3048
3092
  "warnings": _warnings_from_verification_hints(verification_hints),
3049
3093
  "verification": {
3050
3094
  "app_exists": True,
3095
+ "schema_read_unavailable": schema_unavailable,
3051
3096
  "views_read_unavailable": views_unavailable,
3052
3097
  "workflow_read_unavailable": workflow_unavailable,
3053
3098
  },
3054
- "verified": not views_unavailable and not workflow_unavailable,
3099
+ "verified": not schema_unavailable and not views_unavailable and not workflow_unavailable,
3055
3100
  **response.model_dump(mode="json"),
3056
3101
  }
3057
3102
 
@@ -3064,8 +3109,9 @@ class AiBuilderFacade:
3064
3109
  permission_summary = self._read_app_permission_summary(profile=profile, app_key=app_key)
3065
3110
  result["message"] = "read app config summary"
3066
3111
  result["editability"] = {
3112
+ "can_edit_app_base": self._derive_can_edit_app_base(profile=profile, permission_summary=permission_summary),
3067
3113
  "can_edit_form": permission_summary.get("can_edit_app"),
3068
- "can_edit_flow": permission_summary.get("can_edit_app"),
3114
+ "can_edit_flow": permission_summary.get("can_manage_data"),
3069
3115
  "can_edit_views": permission_summary.get("can_manage_data"),
3070
3116
  "can_edit_charts": permission_summary.get("can_manage_data"),
3071
3117
  }
@@ -6706,6 +6752,7 @@ class AiBuilderFacade:
6706
6752
 
6707
6753
  for patch in request.upsert_charts:
6708
6754
  try:
6755
+ config_update_requested = _chart_patch_updates_chart_config(patch)
6709
6756
  chart_visible_auth = (
6710
6757
  self._compile_visibility_to_chart_visible_auth(profile=profile, visibility=patch.visibility)
6711
6758
  if patch.visibility is not None
@@ -6809,18 +6856,17 @@ class AiBuilderFacade:
6809
6856
  existing_by_name.pop(old_name, None)
6810
6857
  existing_by_name.setdefault(patch.name, []).append(deepcopy(updated_chart))
6811
6858
 
6812
- config_payload = _build_public_chart_config_payload(
6813
- patch=patch,
6814
- app_key=app_key,
6815
- field_lookup=field_lookup,
6816
- qingbi_fields_by_id=qingbi_fields_by_id,
6817
- )
6818
- if isinstance(chart_visible_auth, dict):
6819
- raw_data_config = config_payload.get("rawDataConfigDTO")
6820
- if isinstance(raw_data_config, dict):
6821
- raw_data_config["authInfo"] = deepcopy(chart_visible_auth)
6822
- self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload)
6823
- if existing is not None and chart_id not in updated_ids:
6859
+ config_updated = False
6860
+ if existing is None or config_update_requested:
6861
+ config_payload = _build_public_chart_config_payload(
6862
+ patch=patch,
6863
+ app_key=app_key,
6864
+ field_lookup=field_lookup,
6865
+ qingbi_fields_by_id=qingbi_fields_by_id,
6866
+ )
6867
+ self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload)
6868
+ config_updated = True
6869
+ if existing is not None and chart_id not in updated_ids and config_updated:
6824
6870
  updated_ids.append(chart_id)
6825
6871
  if patch.question_config:
6826
6872
  self._request_backend(
@@ -6836,6 +6882,8 @@ class AiBuilderFacade:
6836
6882
  path=f"/chart/{chart_id}/user/config",
6837
6883
  json_body=patch.user_config,
6838
6884
  )
6885
+ if existing is not None and chart_id not in updated_ids and (patch.question_config or patch.user_config):
6886
+ updated_ids.append(chart_id)
6839
6887
  chart_results.append(
6840
6888
  {
6841
6889
  "chart_id": chart_id,
@@ -6992,11 +7040,12 @@ class AiBuilderFacade:
6992
7040
  permission_outcomes: list[PermissionCheckOutcome] = []
6993
7041
  dash_key = str(request.dash_key or "").strip()
6994
7042
  creating = not dash_key
7043
+ sections_requested = creating or bool(request.sections)
6995
7044
  verify_dash_name = creating or request.dash_name is not None
6996
7045
  verify_dash_icon = bool(request.icon or request.color)
6997
7046
  verify_auth = request.visibility is not None or request.auth is not None
6998
- verify_hide_copyright = request.hide_copyright is not None
6999
- verify_dash_global_config = request.dash_global_config is not None
7047
+ verify_hide_copyright = request.hide_copyright is not None and sections_requested
7048
+ verify_dash_global_config = request.dash_global_config is not None and sections_requested
7000
7049
  verify_tags = creating or request.package_tag_id is not None
7001
7050
  requested_visibility = request.visibility
7002
7051
  if requested_visibility is None and isinstance(request.auth, dict) and request.auth:
@@ -7073,6 +7122,25 @@ class AiBuilderFacade:
7073
7122
  if package_edit_outcome.block is not None:
7074
7123
  return package_edit_outcome.block
7075
7124
  permission_outcomes.append(package_edit_outcome)
7125
+ if not sections_requested:
7126
+ unsupported_base_only_keys: list[str] = []
7127
+ if request.hide_copyright is not None:
7128
+ unsupported_base_only_keys.append("hide_copyright")
7129
+ if request.dash_global_config is not None:
7130
+ unsupported_base_only_keys.append("dash_global_config")
7131
+ if request.config:
7132
+ unsupported_base_only_keys.append("config")
7133
+ if unsupported_base_only_keys:
7134
+ return _failed(
7135
+ "PORTAL_SECTIONS_REQUIRED",
7136
+ "editing a portal without sections only supports base-info updates",
7137
+ normalized_args=normalized_args,
7138
+ details={
7139
+ "unsupported_without_sections": unsupported_base_only_keys,
7140
+ "fix_hint": "Pass sections when changing layout or global portal config, or omit those keys for visibility/icon/package updates.",
7141
+ },
7142
+ suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
7143
+ )
7076
7144
  try:
7077
7145
  if creating:
7078
7146
  create_payload = _build_public_portal_base_payload(
@@ -7098,7 +7166,6 @@ class AiBuilderFacade:
7098
7166
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
7099
7167
  )
7100
7168
  base_payload = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
7101
- component_payload = self._build_portal_components_from_sections(profile=profile, sections=request.sections)
7102
7169
  update_payload = _build_public_portal_base_payload(
7103
7170
  dash_name=request.dash_name or str(base_payload.get("dashName") or "").strip() or "未命名门户",
7104
7171
  package_tag_id=target_package_tag_id,
@@ -7110,8 +7177,10 @@ class AiBuilderFacade:
7110
7177
  config=request.config,
7111
7178
  base_payload=base_payload,
7112
7179
  )
7113
- update_payload["components"] = component_payload
7114
- self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
7180
+ if sections_requested:
7181
+ component_payload = self._build_portal_components_from_sections(profile=profile, sections=request.sections)
7182
+ update_payload["components"] = component_payload
7183
+ self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
7115
7184
  self.portals.portal_update_base_info(
7116
7185
  profile=profile,
7117
7186
  dash_key=dash_key,
@@ -7148,11 +7217,14 @@ class AiBuilderFacade:
7148
7217
  publish_failed = True
7149
7218
 
7150
7219
  draft_components = draft_result.get("components") if isinstance(draft_result, dict) else None
7151
- expected_count = len(request.sections)
7152
- draft_verified = isinstance(draft_components, list) and len(draft_components) == expected_count
7220
+ expected_count = len(request.sections) if sections_requested else None
7221
+ draft_verified = isinstance(draft_result, dict) and (
7222
+ expected_count is None or (isinstance(draft_components, list) and len(draft_components) == expected_count)
7223
+ )
7153
7224
  draft_meta_verified, draft_meta_mismatches = _verify_portal_readback(
7154
7225
  actual=draft_result,
7155
7226
  expected_payload=update_payload,
7227
+ expected_visibility=requested_visibility.model_dump(mode="json") if requested_visibility is not None else None,
7156
7228
  expected_section_count=expected_count,
7157
7229
  requested_config_keys=set((request.config or {}).keys()),
7158
7230
  verify_dash_name=verify_dash_name,
@@ -7168,12 +7240,18 @@ class AiBuilderFacade:
7168
7240
  if request.publish:
7169
7241
  live_verified = (
7170
7242
  isinstance(live_result, dict)
7171
- and isinstance(live_result.get("components"), list)
7172
- and len(live_result.get("components")) == expected_count
7243
+ and (
7244
+ expected_count is None
7245
+ or (
7246
+ isinstance(live_result.get("components"), list)
7247
+ and len(live_result.get("components")) == expected_count
7248
+ )
7249
+ )
7173
7250
  )
7174
7251
  live_meta_verified, live_meta_mismatches = _verify_portal_readback(
7175
7252
  actual=live_result,
7176
7253
  expected_payload=update_payload,
7254
+ expected_visibility=requested_visibility.model_dump(mode="json") if requested_visibility is not None else None,
7177
7255
  expected_section_count=expected_count,
7178
7256
  requested_config_keys=set((request.config or {}).keys()),
7179
7257
  verify_dash_name=verify_dash_name,
@@ -7207,7 +7285,15 @@ class AiBuilderFacade:
7207
7285
  "status": status,
7208
7286
  "error_code": error_code,
7209
7287
  "recoverable": not verified,
7210
- "message": "applied portal" if verified else "applied portal; draft/live verification pending",
7288
+ "message": (
7289
+ "updated portal base info"
7290
+ if verified and not sections_requested
7291
+ else "applied portal"
7292
+ if verified
7293
+ else "updated portal base info; draft/live verification pending"
7294
+ if not sections_requested
7295
+ else "applied portal; draft/live verification pending"
7296
+ ),
7211
7297
  "normalized_args": normalized_args,
7212
7298
  "missing_fields": [],
7213
7299
  "allowed_values": {"section.source_type": ["chart", "view", "grid", "filter", "text", "link"]},
@@ -7402,17 +7488,35 @@ class AiBuilderFacade:
7402
7488
  sync_result = {**sync_result, "button_config_restored": True}
7403
7489
  return sync_result
7404
7490
 
7405
- def _load_views_result(self, *, profile: str, app_key: str, tolerate_404: bool) -> tuple[Any, bool]:
7491
+ def _load_views_result(
7492
+ self,
7493
+ *,
7494
+ profile: str,
7495
+ app_key: str,
7496
+ tolerate_404: bool,
7497
+ tolerate_permission_restricted: bool = False,
7498
+ ) -> tuple[Any, bool]:
7406
7499
  try:
7407
7500
  views = self.views.view_list_flat(profile=profile, app_key=app_key)
7408
7501
  except (QingflowApiError, RuntimeError) as error:
7409
7502
  api_error = _coerce_api_error(error)
7410
- if api_error.http_status == 404:
7503
+ if api_error.http_status == 404 or (
7504
+ tolerate_permission_restricted and _is_permission_restricted_api_error(api_error)
7505
+ ):
7411
7506
  try:
7412
7507
  legacy_views = self.views.view_list(profile=profile, app_key=app_key)
7413
7508
  except (QingflowApiError, RuntimeError) as legacy_error:
7414
7509
  legacy_api_error = _coerce_api_error(legacy_error)
7415
- if tolerate_404 and legacy_api_error.http_status == 404:
7510
+ if (
7511
+ tolerate_404
7512
+ and (
7513
+ legacy_api_error.http_status == 404
7514
+ or (
7515
+ tolerate_permission_restricted
7516
+ and _is_permission_restricted_api_error(legacy_api_error)
7517
+ )
7518
+ )
7519
+ ):
7416
7520
  return [], True
7417
7521
  raise
7418
7522
  legacy_result = legacy_views.get("result")
@@ -7429,19 +7533,38 @@ class AiBuilderFacade:
7429
7533
  legacy_views = self.views.view_list(profile=profile, app_key=app_key)
7430
7534
  except (QingflowApiError, RuntimeError) as legacy_error:
7431
7535
  legacy_api_error = _coerce_api_error(legacy_error)
7432
- if tolerate_404 and legacy_api_error.http_status == 404:
7536
+ if (
7537
+ tolerate_404
7538
+ and (
7539
+ legacy_api_error.http_status == 404
7540
+ or (
7541
+ tolerate_permission_restricted
7542
+ and _is_permission_restricted_api_error(legacy_api_error)
7543
+ )
7544
+ )
7545
+ ):
7433
7546
  return normalized_views, False
7434
7547
  raise
7435
7548
  legacy_result = legacy_views.get("result")
7436
7549
  legacy_normalized = _normalize_view_collection(legacy_result)
7437
7550
  return legacy_normalized or normalized_views, False
7438
7551
 
7439
- def _load_workflow_result(self, *, profile: str, app_key: str, tolerate_404: bool) -> tuple[Any, bool]:
7552
+ def _load_workflow_result(
7553
+ self,
7554
+ *,
7555
+ profile: str,
7556
+ app_key: str,
7557
+ tolerate_404: bool,
7558
+ tolerate_permission_restricted: bool = False,
7559
+ ) -> tuple[Any, bool]:
7440
7560
  try:
7441
7561
  workflow = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key)
7442
7562
  except (QingflowApiError, RuntimeError) as error:
7443
7563
  api_error = _coerce_api_error(error)
7444
- if tolerate_404 and api_error.http_status == 404:
7564
+ if tolerate_404 and (
7565
+ api_error.http_status == 404
7566
+ or (tolerate_permission_restricted and _is_permission_restricted_api_error(api_error))
7567
+ ):
7445
7568
  return [], True
7446
7569
  raise
7447
7570
  return workflow.get("result"), False
@@ -8733,6 +8856,11 @@ def _build_public_chart_config_payload(
8733
8856
  return payload
8734
8857
 
8735
8858
 
8859
+ def _chart_patch_updates_chart_config(patch: ChartUpsertPatch) -> bool:
8860
+ explicit_fields = set(getattr(patch, "model_fields_set", set()) or set())
8861
+ return bool({"dimension_field_ids", "indicator_field_ids", "filters", "config"} & explicit_fields)
8862
+
8863
+
8736
8864
  def _build_public_portal_base_payload(
8737
8865
  *,
8738
8866
  dash_name: str,
@@ -8962,7 +9090,8 @@ def _verify_portal_readback(
8962
9090
  *,
8963
9091
  actual: Any,
8964
9092
  expected_payload: dict[str, Any],
8965
- expected_section_count: int,
9093
+ expected_visibility: dict[str, Any] | None,
9094
+ expected_section_count: int | None,
8966
9095
  requested_config_keys: set[str],
8967
9096
  verify_dash_name: bool,
8968
9097
  verify_dash_icon: bool,
@@ -8975,14 +9104,19 @@ def _verify_portal_readback(
8975
9104
  if not isinstance(actual, dict):
8976
9105
  return False, ["portal readback payload is unavailable"]
8977
9106
  components = actual.get("components")
8978
- if not isinstance(components, list) or len(components) != expected_section_count:
9107
+ if expected_section_count is not None and (not isinstance(components, list) or len(components) != expected_section_count):
8979
9108
  mismatches.append(f"components expected {expected_section_count}, got {len(components) if isinstance(components, list) else 'unavailable'}")
8980
9109
  if verify_dash_name and str(actual.get("dashName") or "").strip() != str(expected_payload.get("dashName") or "").strip():
8981
9110
  mismatches.append("dash_name")
8982
9111
  if verify_dash_icon and str(actual.get("dashIcon") or "") != str(expected_payload.get("dashIcon") or ""):
8983
9112
  mismatches.append("dash_icon")
8984
- if verify_auth and not _mapping_contains(actual.get("auth"), expected_payload.get("auth")):
8985
- mismatches.append("auth")
9113
+ if verify_auth:
9114
+ if expected_visibility is not None:
9115
+ actual_visibility = _public_visibility_from_member_auth(actual.get("auth"))
9116
+ if not _visibility_matches_expected(actual_visibility, expected_visibility):
9117
+ mismatches.append("auth")
9118
+ elif not _mapping_contains(actual.get("auth"), expected_payload.get("auth")):
9119
+ mismatches.append("auth")
8986
9120
  if verify_hide_copyright and bool(actual.get("hideCopyright", False)) != bool(expected_payload.get("hideCopyright", False)):
8987
9121
  mismatches.append("hide_copyright")
8988
9122
  if verify_dash_global_config and not _mapping_contains(actual.get("dashGlobalConfig") or {}, expected_payload.get("dashGlobalConfig") or {}):
@@ -9190,7 +9324,11 @@ def _visibility_matches_expected(actual: Any, expected: Any) -> bool:
9190
9324
  if expected_text and sorted_values(actual_group, text_key) != expected_text:
9191
9325
  return False
9192
9326
 
9193
- if "include_sub_departs" in expected_selectors and actual_selectors.get("include_sub_departs") != expected_selectors.get("include_sub_departs"):
9327
+ if (
9328
+ "include_sub_departs" in expected_selectors
9329
+ and expected_selectors.get("include_sub_departs") is not None
9330
+ and actual_selectors.get("include_sub_departs") != expected_selectors.get("include_sub_departs")
9331
+ ):
9194
9332
  return False
9195
9333
  return True
9196
9334
 
@@ -12184,6 +12322,7 @@ def _warnings_from_verification_hints(hints: list[str]) -> list[dict[str, Any]]:
12184
12322
  "package attachment not verified": _warning("PACKAGE_ATTACHMENT_UNVERIFIED", "package attachment is not verified"),
12185
12323
  "layout has unplaced fields": _warning("LAYOUT_HAS_UNPLACED_FIELDS", "layout still contains unplaced fields"),
12186
12324
  "no public views detected": _warning("NO_PUBLIC_VIEWS", "no public views were detected"),
12325
+ "schema_read_unavailable": _warning("SCHEMA_READ_UNAVAILABLE", "schema summary readback is unavailable"),
12187
12326
  "views_read_unavailable": _warning("VIEWS_READ_UNAVAILABLE", "views summary readback is unavailable"),
12188
12327
  "workflow_read_unavailable": _warning("WORKFLOW_READ_UNAVAILABLE", "workflow summary readback is unavailable"),
12189
12328
  }
@@ -160,7 +160,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
160
160
  portal_apply.add_argument("--dash-name", default="")
161
161
  portal_apply.add_argument("--package-id", type=int)
162
162
  portal_apply.add_argument("--publish", action=argparse.BooleanOptionalAction, default=True)
163
- portal_apply.add_argument("--sections-file", required=True)
163
+ portal_apply.add_argument("--sections-file")
164
164
  portal_apply.add_argument("--visibility-file")
165
165
  portal_apply.add_argument("--auth-file")
166
166
  portal_apply.add_argument("--icon")
@@ -513,13 +513,14 @@ def _handle_portal_apply(args: argparse.Namespace, context: CliContext) -> dict:
513
513
  "portal apply requires either --dash-key, or --package-id together with --dash-name.",
514
514
  fix_hint="Use `--dash-key` for an existing portal. For create mode, pass `--package-id --dash-name`.",
515
515
  )
516
+ sections = [] if not args.sections_file else require_list_arg(args.sections_file, option_name="--sections-file")
516
517
  return context.builder.portal_apply(
517
518
  profile=args.profile,
518
519
  dash_key=args.dash_key,
519
520
  dash_name=args.dash_name,
520
521
  package_id=args.package_id,
521
522
  publish=bool(args.publish),
522
- sections=require_list_arg(args.sections_file, option_name="--sections-file"),
523
+ sections=sections,
523
524
  visibility=load_object_arg(args.visibility_file, option_name="--visibility-file"),
524
525
  auth=load_object_arg(args.auth_file, option_name="--auth-file"),
525
526
  icon=args.icon,
@@ -118,6 +118,7 @@ def _format_app_get(result: dict[str, Any]) -> str:
118
118
  if editability:
119
119
  lines.append(
120
120
  "Editability: "
121
+ f"app_base={editability.get('can_edit_app_base')} / "
121
122
  f"form={editability.get('can_edit_form')} / "
122
123
  f"flow={editability.get('can_edit_flow')} / "
123
124
  f"views={editability.get('can_edit_views')} / "
@@ -268,6 +269,7 @@ def _format_import_status(result: dict[str, Any]) -> str:
268
269
  f"Process ID: {result.get('process_id_str') or '-'}",
269
270
  f"Total Rows: {result.get('total') or 0}",
270
271
  f"Finished Rows: {result.get('finished') or 0}",
272
+ f"Succeeded Rows: {result.get('succeeded') or 0}",
271
273
  f"Failed Rows: {result.get('failed') or 0}",
272
274
  f"Progress: {result.get('progress') or '-'}",
273
275
  ]
@@ -343,7 +343,7 @@ def _trim_record_schema(payload: JSONObject) -> None:
343
343
  payload["required_fields"] = required_fields
344
344
  payload["optional_fields"] = optional_fields
345
345
 
346
- for key in ("required_fields", "optional_fields", "runtime_linked_required_fields", "fields"):
346
+ for key in ("required_fields", "optional_fields", "runtime_linked_required_fields", "fields", "ambiguous_fields"):
347
347
  if key in payload:
348
348
  payload[key] = _compact_schema_fields(payload.get(key), template_map=template_map)
349
349
 
@@ -353,11 +353,12 @@ def _trim_record_schema(payload: JSONObject) -> None:
353
353
  _pick(item, ("field_id", "title")) for item in payload.get(key) if isinstance(item, dict)
354
354
  ]
355
355
 
356
- for key in ("workflow_node", "view_resolution", "field_count", "record_context_probe", "view_probe_summary", "ambiguous_fields"):
356
+ for key in ("workflow_node", "view_resolution", "field_count"):
357
357
  payload.pop(key, None)
358
358
 
359
359
 
360
360
  def _trim_record_write(payload: JSONObject) -> None:
361
+ payload.pop("verification", None)
361
362
  data = payload.get("data")
362
363
  if not isinstance(data, dict):
363
364
  return
@@ -370,6 +371,24 @@ def _trim_record_write(payload: JSONObject) -> None:
370
371
  data["resource"] = resource
371
372
  else:
372
373
  data.pop("resource", None)
374
+ verification = data.get("verification")
375
+ if isinstance(verification, dict):
376
+ compact_verification = _pick(
377
+ verification,
378
+ (
379
+ "verified",
380
+ "verification_mode",
381
+ "field_level_verified",
382
+ ),
383
+ )
384
+ if compact_verification:
385
+ data["verification"] = compact_verification
386
+ else:
387
+ data.pop("verification", None)
388
+ for key in ("blockers", "field_errors", "confirmation_requests", "resolved_fields"):
389
+ value = data.get(key)
390
+ if value in (None, [], {}, ""):
391
+ data.pop(key, None)
373
392
 
374
393
 
375
394
  def _trim_record_get(payload: JSONObject) -> None:
@@ -386,6 +405,12 @@ def _trim_record_get(payload: JSONObject) -> None:
386
405
  record = data.get("record")
387
406
  if isinstance(record, dict):
388
407
  compact["record"] = record
408
+ normalized_record = data.get("normalized_record")
409
+ if isinstance(normalized_record, dict):
410
+ compact["normalized_record"] = normalized_record
411
+ normalized_ambiguous_fields = data.get("normalized_ambiguous_fields")
412
+ if isinstance(normalized_ambiguous_fields, dict):
413
+ compact["normalized_ambiguous_fields"] = normalized_ambiguous_fields
389
414
  payload["data"] = compact
390
415
 
391
416
 
@@ -558,11 +583,14 @@ def _compact_schema_field(item: Any, *, template_map: dict[str, Any] | None) ->
558
583
 
559
584
 
560
585
  def _compact_import_column(item: dict[str, Any]) -> dict[str, Any]:
561
- compact: dict[str, Any] = {
562
- "title": item.get("title"),
563
- "kind": item.get("kind") or item.get("write_kind"),
564
- "required": bool(item.get("required")),
565
- }
586
+ compact: dict[str, Any] = {}
587
+ title = item.get("title")
588
+ if title not in (None, ""):
589
+ compact["title"] = title
590
+ kind = item.get("kind") or item.get("write_kind")
591
+ if kind not in (None, ""):
592
+ compact["kind"] = kind
593
+ compact["required"] = bool(item.get("required"))
566
594
  options = item.get("options")
567
595
  if isinstance(options, list) and options:
568
596
  compact["options"] = options
@@ -590,18 +618,24 @@ def _trim_import_verify_payload(payload: JSONObject) -> None:
590
618
  issues = payload.get("issues") if isinstance(payload.get("issues"), list) else []
591
619
  issue_summary = _summarize_import_issues(issues)
592
620
  payload["issue_summary"] = issue_summary
621
+ columns = payload.get("columns")
622
+ if "expected_columns" not in payload and isinstance(columns, list):
623
+ payload["expected_columns"] = columns
593
624
  file_name = payload.get("file_name")
594
625
  if not file_name:
595
626
  file_path = payload.get("file_path")
596
627
  if isinstance(file_path, str) and file_path:
597
628
  payload["file_name"] = file_path.split("/")[-1]
598
- for key in ("issues", "apply_rows", "expected_columns", "schema_fingerprint", "import_capability", "file_path", "file_sha256", "verified_file_sha256", "file_format", "auto_normalized", "local_precheck_limited"):
629
+ for key in ("apply_rows", "schema_fingerprint", "import_capability", "file_sha256", "verified_file_sha256", "file_format", "local_precheck_limited"):
599
630
  payload.pop(key, None)
600
631
 
601
632
 
602
633
  def _trim_import_repair_payload(payload: JSONObject) -> None:
603
634
  payload["verification_id"] = payload.get("new_verification_id") or payload.get("verification_id")
604
- for key in ("new_verification_id", "source_file_path", "repaired_file_path", "post_repair_issues", "verification"):
635
+ post_repair_issues = payload.get("post_repair_issues")
636
+ if isinstance(post_repair_issues, list):
637
+ payload["post_repair_issue_summary"] = _summarize_import_issues(post_repair_issues)
638
+ for key in ("new_verification_id", "verification"):
605
639
  payload.pop(key, None)
606
640
 
607
641
 
@@ -615,7 +649,13 @@ def _trim_import_status_payload(payload: JSONObject) -> None:
615
649
  success_rows = payload.get("success_rows")
616
650
  failed_rows = payload.get("failed_rows")
617
651
  payload["total"] = total_rows
618
- payload["finished"] = success_rows
652
+ if isinstance(success_rows, int) and isinstance(failed_rows, int):
653
+ payload["finished"] = success_rows + failed_rows
654
+ elif isinstance(success_rows, int):
655
+ payload["finished"] = success_rows
656
+ else:
657
+ payload["finished"] = None
658
+ payload["succeeded"] = success_rows
619
659
  payload["failed"] = failed_rows
620
660
  for key in (
621
661
  "matched_by",
@@ -38,7 +38,7 @@ def build_builder_server() -> FastMCP:
38
38
  "If creating or updating an app package may be appropriate, use package_apply with explicit user intent; otherwise use package_get and app_resolve to locate resources, "
39
39
  "app_get/app_get_fields/app_repair_code_blocks/app_get_layout/app_get_views/app_get_flow/app_get_charts/portal_list/portal_get/view_get/chart_get for configuration reads, "
40
40
  "member_search/role_search/role_create when workflow assignees must come from the directory or role catalog, preferring roles over explicit members unless the user explicitly names members, "
41
- "then app_schema_apply/app_layout_apply/app_flow_apply/app_views_apply/app_charts_apply/portal_apply to execute normalized patches; these apply tools perform planning, normalization, and dependency checks internally where applicable. Schema/layout/views noop requests skip publish, charts are immediate-live without publish and resolve targets by chart_id first then exact unique chart name, portal updates are replace-only and publish=false only guarantees draft/base-info updates, and flow should use publish=false whenever you only want draft/precheck behavior. "
41
+ "then app_schema_apply/app_layout_apply/app_flow_apply/app_views_apply/app_charts_apply/portal_apply to execute normalized patches; these apply tools perform planning, normalization, and dependency checks internally where applicable. Schema/layout/views noop requests skip publish, charts are immediate-live without publish and resolve targets by chart_id first then exact unique chart name, portal updates use replace semantics only when sections are supplied and edit-mode base-info-only updates may omit sections, publish=false only guarantees draft/base-info updates, and flow should use publish=false whenever you only want draft/precheck behavior. "
42
42
  "For code_block fields with output bindings, always use qf_output assignment rather than const/let qf_output, and use app_repair_code_blocks when an existing form hangs because output-bound fields stay loading. "
43
43
  "Use package_apply to manage package metadata, visibility, grouping, and ordering, and app_publish_verify for explicit final publish verification. "
44
44
  "For workflow edits, keep the public builder surface on stable linear flows only: start/approve/fill/copy/webhook/end. Branch and condition nodes are intentionally disabled because the backend workflow route is not front-end stable for those node types. Declare node assignees and editable fields explicitly. "
@@ -1763,7 +1763,7 @@ class AiBuilderTools(ToolBase):
1763
1763
  dash_name: str = "",
1764
1764
  package_id: int | None = None,
1765
1765
  publish: bool = True,
1766
- sections: list[JSONObject],
1766
+ sections: list[JSONObject] | None = None,
1767
1767
  visibility: JSONObject | None = None,
1768
1768
  auth: JSONObject | None = None,
1769
1769
  icon: str | None = None,
@@ -3058,7 +3058,9 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3058
3058
  "execution_notes": [
3059
3059
  "returns builder-side app configuration summary and editability",
3060
3060
  "use this as the default builder discovery read before fields/layout/views/flow/charts detail reads",
3061
- "editability reflects builder permissions, not end-user data visibility",
3061
+ "editability is route-aware builder capability summary, not end-user data visibility",
3062
+ "can_edit_app_base covers app base-info writes such as app_name, icon, and visibility",
3063
+ "can_edit_form covers form/schema routes only and does not imply app base-info writes",
3062
3064
  "returns normalized app visibility when backend auth is readable",
3063
3065
  ],
3064
3066
  "minimal_example": {
@@ -3179,14 +3181,15 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3179
3181
  "chart.filter.operator": [member.value for member in ViewFilterOperator],
3180
3182
  **deepcopy(_VISIBILITY_ALLOWED_VALUES),
3181
3183
  },
3182
- "execution_notes": [
3183
- "app_charts_apply is immediate-live and does not publish",
3184
- "chart matching precedence is chart_id first, then exact unique chart name",
3185
- "when chart names are not unique, supply chart_id instead of guessing by name",
3186
- "successful create results must return a real backend chart_id",
3187
- "upsert_charts[].visibility compiles to QingBI visibleAuth; omit it on updates to preserve the existing visibleAuth",
3188
- *_VISIBILITY_EXECUTION_NOTES,
3189
- ],
3184
+ "execution_notes": [
3185
+ "app_charts_apply is immediate-live and does not publish",
3186
+ "chart matching precedence is chart_id first, then exact unique chart name",
3187
+ "when chart names are not unique, supply chart_id instead of guessing by name",
3188
+ "successful create results must return a real backend chart_id",
3189
+ "upsert_charts[].visibility compiles to QingBI base visibleAuth only",
3190
+ "visibility-only updates keep the existing chart config and do not rewrite rawDataConfigDTO.authInfo",
3191
+ *_VISIBILITY_EXECUTION_NOTES,
3192
+ ],
3190
3193
  "minimal_example": {
3191
3194
  "profile": "default",
3192
3195
  "app_key": "APP_KEY",
@@ -3242,14 +3245,15 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3242
3245
  "dashStyleConfigBO": "dash_style_config",
3243
3246
  },
3244
3247
  "allowed_values": {"section.source_type": ["chart", "view", "grid", "filter", "text", "link"], **deepcopy(_VISIBILITY_ALLOWED_VALUES)},
3245
- "execution_notes": [
3246
- "use exactly one resource mode",
3247
- "update mode: dash_key",
3248
- "create mode: package_id + dash_name",
3249
- "portal_apply uses replace semantics for sections",
3250
- "remove a section by omitting it from the new sections list",
3251
- "package_id is required when creating a new portal",
3252
- "publish=false only guarantees draft and base-info updates; it does not claim live has changed",
3248
+ "execution_notes": [
3249
+ "use exactly one resource mode",
3250
+ "update mode: dash_key",
3251
+ "create mode: package_id + dash_name",
3252
+ "portal_apply uses replace semantics for sections",
3253
+ "when editing an existing portal, sections may be omitted to update only base info such as visibility, icon, or package",
3254
+ "remove a section by omitting it from the new sections list",
3255
+ "package_id is required when creating a new portal",
3256
+ "publish=false only guarantees draft and base-info updates; it does not claim live has changed",
3253
3257
  "chart_ref resolves by chart_id first, then exact unique chart_name",
3254
3258
  "view_ref resolves by view_key first, then exact unique view_name",
3255
3259
  "position.pc/mobile is the canonical portal layout shape",
@@ -760,6 +760,8 @@ class ImportTools(ToolBase):
760
760
  error_code="CONFIG_ERROR",
761
761
  message="record_import_status_get accepts import_id or process_id_str, but not both at the same time",
762
762
  extra={
763
+ "import_id": normalized_import_id,
764
+ "process_id_str": normalized_process_id,
763
765
  "details": {
764
766
  "fix_hint": "Use only one of `import_id` or `process_id_str`. You may pass `app_key` as an optional routing hint for direct method compatibility.",
765
767
  }
@@ -770,6 +772,8 @@ class ImportTools(ToolBase):
770
772
  error_code="CONFIG_ERROR",
771
773
  message="record_import_status_get requires at least one selector: process_id_str, import_id, or app_key",
772
774
  extra={
775
+ "import_id": normalized_import_id,
776
+ "process_id_str": normalized_process_id,
773
777
  "details": {
774
778
  "fix_hint": "Use `process_id_str` or `import_id` for a known import, or use only `app_key` to inspect the latest import in that app.",
775
779
  }
@@ -783,6 +787,9 @@ class ImportTools(ToolBase):
783
787
  if local_job is None and normalized_process_id:
784
788
  matches = [item for item in self._job_store.list() if _normalize_optional_text(item.get("process_id_str")) == normalized_process_id]
785
789
  local_job = matches[0] if len(matches) == 1 else None
790
+ effective_process_id = normalized_process_id
791
+ if effective_process_id is None and isinstance(local_job, dict):
792
+ effective_process_id = _normalize_optional_text(local_job.get("process_id_str"))
786
793
  resolved_app_key = normalized_app_key
787
794
  if not resolved_app_key and isinstance(local_job, dict):
788
795
  resolved_app_key = str(local_job.get("app_key") or "").strip()
@@ -791,6 +798,8 @@ class ImportTools(ToolBase):
791
798
  error_code="CONFIG_ERROR",
792
799
  message="record_import_status_get could not determine app_key from the provided selector",
793
800
  extra={
801
+ "import_id": normalized_import_id,
802
+ "process_id_str": effective_process_id,
794
803
  "details": {
795
804
  "fix_hint": "Use the original `app_key`, or call import status with the latest-import mode: only `app_key`.",
796
805
  }
@@ -809,13 +818,18 @@ class ImportTools(ToolBase):
809
818
  matched_record, matched_by = _match_import_record(
810
819
  records,
811
820
  local_job=local_job,
812
- process_id_str=normalized_process_id,
821
+ import_id=normalized_import_id,
822
+ process_id_str=effective_process_id,
813
823
  )
814
824
  if matched_record is None:
815
825
  return self._failed_status_result(
816
826
  error_code="IMPORT_STATUS_AMBIGUOUS",
817
827
  message="could not uniquely resolve an import record from the provided identifiers",
818
- extra={"matched_by": matched_by},
828
+ extra={
829
+ "import_id": normalized_import_id,
830
+ "process_id_str": effective_process_id,
831
+ "matched_by": matched_by,
832
+ },
819
833
  )
820
834
  normalized_process = _normalize_optional_text(
821
835
  matched_record.get("processIdStr") or matched_record.get("processId") or matched_record.get("process_id_str")
@@ -2079,6 +2093,7 @@ def _match_import_record(
2079
2093
  records: list[JSONObject],
2080
2094
  *,
2081
2095
  local_job: dict[str, Any] | None,
2096
+ import_id: str | None,
2082
2097
  process_id_str: str | None,
2083
2098
  ) -> tuple[JSONObject | None, str | None]:
2084
2099
  if process_id_str:
@@ -2091,6 +2106,16 @@ def _match_import_record(
2091
2106
  return exact[0], "process_id_str"
2092
2107
  if len(exact) > 1:
2093
2108
  return None, "process_id_str"
2109
+ if import_id:
2110
+ exact = [
2111
+ item
2112
+ for item in records
2113
+ if import_id in _extract_import_record_ids(item)
2114
+ ]
2115
+ if len(exact) == 1:
2116
+ return exact[0], "import_id"
2117
+ if len(exact) > 1:
2118
+ return None, "import_id"
2094
2119
  if isinstance(local_job, dict):
2095
2120
  source_file_name = _normalize_optional_text(local_job.get("source_file_name"))
2096
2121
  started_at = _parse_utc(local_job.get("started_at"))
@@ -2121,6 +2146,15 @@ def _match_import_record(
2121
2146
  return None, None
2122
2147
 
2123
2148
 
2149
+ def _extract_import_record_ids(record: JSONObject) -> set[str]:
2150
+ identifiers: set[str] = set()
2151
+ for key in ("importId", "import_id", "dataImportId", "data_import_id"):
2152
+ normalized = _normalize_optional_text(record.get(key))
2153
+ if normalized:
2154
+ identifiers.add(normalized)
2155
+ return identifiers
2156
+
2157
+
2124
2158
  def _parse_utc(value: Any) -> datetime | None:
2125
2159
  text = _normalize_optional_text(value)
2126
2160
  if text is None: