@josephyan/qingflow-app-user-mcp 0.2.0-beta.38 → 0.2.0-beta.39

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.38
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.39
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.38 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.39 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.38",
3
+ "version": "0.2.0-beta.39",
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.0b38"
7
+ version = "0.2.0b39"
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.0b38"
5
+ __version__ = "0.2.0b39"
@@ -589,6 +589,12 @@ class ChartUpsertPatch(StrictModel):
589
589
  }
590
590
  if normalized in aliases:
591
591
  payload["chart_type"] = aliases[normalized]
592
+ if isinstance(payload.get("chart_id"), int):
593
+ payload["chart_id"] = str(payload["chart_id"])
594
+ if isinstance(payload.get("dimension_field_ids"), list):
595
+ payload["dimension_field_ids"] = [str(item) for item in payload["dimension_field_ids"] if item is not None and str(item).strip()]
596
+ if isinstance(payload.get("indicator_field_ids"), list):
597
+ payload["indicator_field_ids"] = [str(item) for item in payload["indicator_field_ids"] if item is not None and str(item).strip()]
592
598
  return payload
593
599
 
594
600
 
@@ -598,6 +604,18 @@ class ChartApplyRequest(StrictModel):
598
604
  remove_chart_ids: list[str] = Field(default_factory=list)
599
605
  reorder_chart_ids: list[str] = Field(default_factory=list)
600
606
 
607
+ @model_validator(mode="before")
608
+ @classmethod
609
+ def normalize_ids(cls, value: Any) -> Any:
610
+ if not isinstance(value, dict):
611
+ return value
612
+ payload = dict(value)
613
+ for key in ("remove_chart_ids", "reorder_chart_ids"):
614
+ raw = payload.get(key)
615
+ if isinstance(raw, list):
616
+ payload[key] = [str(item) for item in raw if item is not None and str(item).strip()]
617
+ return payload
618
+
601
619
  @model_validator(mode="after")
602
620
  def validate_shape(self) -> "ChartApplyRequest":
603
621
  if not self.upsert_charts and not self.remove_chart_ids and not self.reorder_chart_ids:
@@ -615,6 +633,34 @@ class PortalComponentPositionPatch(StrictModel):
615
633
  mobile_w: int = Field(default=12, validation_alias=AliasChoices("mobile_w", "mobileW"))
616
634
  mobile_h: int = Field(default=8, validation_alias=AliasChoices("mobile_h", "mobileH"))
617
635
 
636
+ @model_validator(mode="before")
637
+ @classmethod
638
+ def normalize_nested_layout(cls, value: Any) -> Any:
639
+ if not isinstance(value, dict):
640
+ return value
641
+ payload = dict(value)
642
+ pc = payload.pop("pc", None)
643
+ mobile = payload.pop("mobile", None)
644
+ if isinstance(pc, dict):
645
+ if "pc_x" not in payload and "x" in pc:
646
+ payload["pc_x"] = pc.get("x")
647
+ if "pc_y" not in payload and "y" in pc:
648
+ payload["pc_y"] = pc.get("y")
649
+ if "pc_w" not in payload and "cols" in pc:
650
+ payload["pc_w"] = pc.get("cols")
651
+ if "pc_h" not in payload and "rows" in pc:
652
+ payload["pc_h"] = pc.get("rows")
653
+ if isinstance(mobile, dict):
654
+ if "mobile_x" not in payload and "x" in mobile:
655
+ payload["mobile_x"] = mobile.get("x")
656
+ if "mobile_y" not in payload and "y" in mobile:
657
+ payload["mobile_y"] = mobile.get("y")
658
+ if "mobile_w" not in payload and "cols" in mobile:
659
+ payload["mobile_w"] = mobile.get("cols")
660
+ if "mobile_h" not in payload and "rows" in mobile:
661
+ payload["mobile_h"] = mobile.get("rows")
662
+ return payload
663
+
618
664
 
619
665
  class PortalChartRefPatch(StrictModel):
620
666
  app_key: str
@@ -2947,9 +2947,9 @@ class AiBuilderFacade:
2947
2947
  if isinstance(item, dict) and item.get("fieldId")
2948
2948
  }
2949
2949
  existing_by_id = {
2950
- str(item.get("chartId") or item.get("chartKey") or ""): deepcopy(item)
2950
+ _extract_chart_identifier(item): deepcopy(item)
2951
2951
  for item in existing_chart_items
2952
- if isinstance(item, dict) and (item.get("chartId") or item.get("chartKey"))
2952
+ if isinstance(item, dict) and _extract_chart_identifier(item)
2953
2953
  }
2954
2954
  existing_by_name = {
2955
2955
  str(item.get("chartName") or "").strip(): deepcopy(item)
@@ -2970,9 +2970,9 @@ class AiBuilderFacade:
2970
2970
  existing = existing_by_id.get(str(patch.chart_id))
2971
2971
  if existing is None:
2972
2972
  existing = existing_by_name.get(patch.name)
2973
- chart_id = str((existing or {}).get("chartId") or (existing or {}).get("chartKey") or patch.chart_id or f"mcp_{uuid4().hex[:16]}")
2973
+ chart_id = _extract_chart_identifier(existing or {}) or str(patch.chart_id or f"mcp_{uuid4().hex[:16]}")
2974
2974
  existing_name = str((existing or {}).get("chartName") or "").strip()
2975
- existing_type = str((existing or {}).get("chartType") or "").strip()
2975
+ existing_type = _normalize_backend_chart_type((existing or {}).get("chartType"))
2976
2976
  target_type = _map_public_chart_type_to_backend(patch.chart_type)
2977
2977
  if existing is None:
2978
2978
  create_payload = {
@@ -2989,7 +2989,15 @@ class AiBuilderFacade:
2989
2989
  "editAuthIncludeSubDept": True,
2990
2990
  }
2991
2991
  create_result = self.charts.qingbi_report_create(profile=profile, payload=create_payload).get("result") or {}
2992
- created_chart_id = str((create_result or {}).get("chartId") or (create_result or {}).get("chartKey") or "").strip()
2992
+ created_chart_id = _extract_chart_identifier(create_result or {})
2993
+ if not created_chart_id:
2994
+ refreshed_items = self.charts.qingbi_report_list(profile=profile, app_key=app_key).get("items") or []
2995
+ refreshed_existing = _find_chart_by_name(
2996
+ refreshed_items,
2997
+ chart_name=patch.name,
2998
+ chart_type=target_type,
2999
+ )
3000
+ created_chart_id = _extract_chart_identifier(refreshed_existing or {})
2993
3001
  if created_chart_id:
2994
3002
  chart_id = created_chart_id
2995
3003
  created_ids.append(chart_id)
@@ -3035,7 +3043,7 @@ class AiBuilderFacade:
3035
3043
  except (QingflowApiError, RuntimeError, ValueError) as error:
3036
3044
  api_error = _coerce_api_error(error) if not isinstance(error, ValueError) else None
3037
3045
  failure = {
3038
- "chart_id": str(patch.chart_id or ""),
3046
+ "chart_id": str(locals().get("chart_id") or patch.chart_id or ""),
3039
3047
  "name": patch.name,
3040
3048
  "status": "failed",
3041
3049
  "message": str(error),
@@ -3082,13 +3090,13 @@ class AiBuilderFacade:
3082
3090
  failed_items.append(failure)
3083
3091
  chart_results.append(failure)
3084
3092
 
3085
- noop = not created_ids and not updated_ids and not removed_ids and not reordered
3093
+ noop = not created_ids and not updated_ids and not removed_ids and not reordered and not failed_items
3086
3094
  try:
3087
3095
  readback_items = self.charts.qingbi_report_list(profile=profile, app_key=app_key).get("items") or []
3088
3096
  readback_ids = {
3089
- str(item.get("chartId") or item.get("chartKey") or "")
3097
+ _extract_chart_identifier(item)
3090
3098
  for item in readback_items
3091
- if isinstance(item, dict) and (item.get("chartId") or item.get("chartKey"))
3099
+ if isinstance(item, dict) and _extract_chart_identifier(item)
3092
3100
  }
3093
3101
  verified = (
3094
3102
  all(chart_id in readback_ids for chart_id in created_ids + updated_ids)
@@ -3096,9 +3104,9 @@ class AiBuilderFacade:
3096
3104
  )
3097
3105
  if request.reorder_chart_ids:
3098
3106
  ordered_readback = [
3099
- str(item.get("chartId") or item.get("chartKey") or "")
3107
+ _extract_chart_identifier(item)
3100
3108
  for item in readback_items
3101
- if isinstance(item, dict) and (item.get("chartId") or item.get("chartKey"))
3109
+ if isinstance(item, dict) and _extract_chart_identifier(item)
3102
3110
  ]
3103
3111
  requested_existing = [chart_id for chart_id in request.reorder_chart_ids if chart_id in ordered_readback]
3104
3112
  verified = verified and ordered_readback[: len(requested_existing)] == requested_existing
@@ -3123,10 +3131,10 @@ class AiBuilderFacade:
3123
3131
  "backend_code": failed_items[0].get("backend_code"),
3124
3132
  "http_status": failed_items[0].get("http_status"),
3125
3133
  "noop": noop,
3126
- "verification": {"charts_verified": verified, "readback_unavailable": readback_unavailable},
3134
+ "verification": {"charts_verified": False if failed_items else verified, "readback_unavailable": readback_unavailable},
3127
3135
  "app_key": app_key,
3128
3136
  "chart_results": chart_results,
3129
- "verified": verified,
3137
+ "verified": False if failed_items else verified,
3130
3138
  }
3131
3139
  return {
3132
3140
  "status": "success" if verified or noop else "partial_success",
@@ -3212,6 +3220,16 @@ class AiBuilderFacade:
3212
3220
  )
3213
3221
  update_payload["components"] = component_payload
3214
3222
  self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
3223
+ self.portals.portal_update_base_info(
3224
+ profile=profile,
3225
+ dash_key=dash_key,
3226
+ payload={
3227
+ "dashName": update_payload.get("dashName"),
3228
+ "dashIcon": update_payload.get("dashIcon"),
3229
+ "auth": deepcopy(update_payload.get("auth")),
3230
+ "tags": deepcopy(update_payload.get("tags") or []),
3231
+ },
3232
+ )
3215
3233
  draft_result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
3216
3234
  except (QingflowApiError, RuntimeError, ValueError) as error:
3217
3235
  api_error = _coerce_api_error(error) if not isinstance(error, ValueError) else None
@@ -4258,6 +4276,61 @@ def _build_public_portal_base_payload(
4258
4276
  return data
4259
4277
 
4260
4278
 
4279
+ def _extract_chart_identifier(chart: Any) -> str:
4280
+ if not isinstance(chart, dict):
4281
+ return ""
4282
+ return str(
4283
+ chart.get("chartId")
4284
+ or chart.get("biChartId")
4285
+ or chart.get("chartKey")
4286
+ or ""
4287
+ ).strip()
4288
+
4289
+
4290
+ def _normalize_backend_chart_type(value: Any) -> str:
4291
+ raw = str(value or "").strip()
4292
+ if not raw:
4293
+ return ""
4294
+ by_code = {
4295
+ "1": "detail",
4296
+ "2": "summary",
4297
+ "3": "indicator",
4298
+ "4": "columnar",
4299
+ "5": "line",
4300
+ "6": "pie",
4301
+ "7": "funnel",
4302
+ "8": "radar",
4303
+ "9": "bar",
4304
+ "10": "scatter",
4305
+ "11": "ring",
4306
+ "12": "dualaxes",
4307
+ "13": "map",
4308
+ "14": "timeline",
4309
+ }
4310
+ return by_code.get(raw, raw.lower())
4311
+
4312
+
4313
+ def _find_chart_by_name(items: Any, *, chart_name: str, chart_type: str | None = None) -> dict[str, Any] | None:
4314
+ target_name = str(chart_name or "").strip()
4315
+ target_type = _normalize_backend_chart_type(chart_type)
4316
+ candidates: list[dict[str, Any]] = []
4317
+ if not isinstance(items, list) or not target_name:
4318
+ return None
4319
+ for item in items:
4320
+ if not isinstance(item, dict):
4321
+ continue
4322
+ item_name = str(item.get("chartName") or "").strip()
4323
+ if item_name != target_name:
4324
+ continue
4325
+ item_type = _normalize_backend_chart_type(item.get("chartType"))
4326
+ if target_type and item_type and item_type != target_type:
4327
+ continue
4328
+ candidates.append(item)
4329
+ if not candidates:
4330
+ return None
4331
+ return deepcopy(candidates[-1])
4332
+
4333
+
4261
4334
  def _portal_position_payload(position: Any) -> dict[str, Any]:
4262
4335
  return {
4263
4336
  "pc": {
@@ -4332,7 +4405,7 @@ def _resolve_chart_reference(*, charts: QingbiReportTools, profile: str, ref: An
4332
4405
  for item in items:
4333
4406
  if not isinstance(item, dict):
4334
4407
  continue
4335
- item_id = str(item.get("chartId") or item.get("chartKey") or "").strip()
4408
+ item_id = _extract_chart_identifier(item)
4336
4409
  item_name = str(item.get("chartName") or "").strip()
4337
4410
  if chart_id and item_id == chart_id:
4338
4411
  return {"chart_id": item_id, "chart_name": item_name, "app_key": app_key}
@@ -18,10 +18,18 @@ class PortalTools(ToolBase):
18
18
  def portal_get(profile: str = DEFAULT_PROFILE, dash_key: str = "", being_draft: bool = False) -> JSONObject:
19
19
  return self.portal_get(profile=profile, dash_key=dash_key, being_draft=being_draft)
20
20
 
21
+ @mcp.tool()
22
+ def portal_get_base_info(profile: str = DEFAULT_PROFILE, dash_key: str = "") -> JSONObject:
23
+ return self.portal_get_base_info(profile=profile, dash_key=dash_key)
24
+
21
25
  @mcp.tool()
22
26
  def portal_create(profile: str = DEFAULT_PROFILE, payload: JSONObject | None = None) -> JSONObject:
23
27
  return self.portal_create(profile=profile, payload=payload or {})
24
28
 
29
+ @mcp.tool(description=self._high_risk_tool_description(operation="update", target="portal base settings"))
30
+ def portal_update_base_info(profile: str = DEFAULT_PROFILE, dash_key: str = "", payload: JSONObject | None = None) -> JSONObject:
31
+ return self.portal_update_base_info(profile=profile, dash_key=dash_key, payload=payload or {})
32
+
25
33
  @mcp.tool(description=self._high_risk_tool_description(operation="update", target="portal configuration"))
26
34
  def portal_update(profile: str = DEFAULT_PROFILE, dash_key: str = "", payload: JSONObject | None = None) -> JSONObject:
27
35
  return self.portal_update(profile=profile, dash_key=dash_key, payload=payload or {})
@@ -59,6 +67,29 @@ class PortalTools(ToolBase):
59
67
 
60
68
  return self._run(profile, runner)
61
69
 
70
+ def portal_get_base_info(self, *, profile: str, dash_key: str) -> JSONObject:
71
+ self._require_dash_key(dash_key)
72
+
73
+ def runner(session_profile, context):
74
+ result = self.backend.request("GET", context, f"/dash/{dash_key}/baseInfo")
75
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "dash_key": dash_key, "result": result}
76
+
77
+ return self._run(profile, runner)
78
+
79
+ def portal_update_base_info(self, *, profile: str, dash_key: str, payload: JSONObject) -> JSONObject:
80
+ self._require_dash_key(dash_key)
81
+ body = self._require_dict(payload)
82
+
83
+ def runner(session_profile, context):
84
+ result = self.backend.request("POST", context, f"/dash/{dash_key}/baseInfo", json_body=body)
85
+ return self._attach_human_review_notice(
86
+ {"profile": profile, "ws_id": session_profile.selected_ws_id, "dash_key": dash_key, "result": result},
87
+ operation="update",
88
+ target="portal base settings",
89
+ )
90
+
91
+ return self._run(profile, runner)
92
+
62
93
  def portal_update(self, *, profile: str, dash_key: str, payload: JSONObject) -> JSONObject:
63
94
  self._require_dash_key(dash_key)
64
95
  body = self._require_dict(payload)