@josephyan/qingflow-cli 0.2.0-beta.999 → 1.0.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.
Files changed (36) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/qingflow_mcp/__init__.py +1 -1
  5. package/src/qingflow_mcp/backend_client.py +109 -0
  6. package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
  7. package/src/qingflow_mcp/builder_facade/models.py +44 -5
  8. package/src/qingflow_mcp/builder_facade/service.py +21 -8
  9. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  10. package/src/qingflow_mcp/cli/commands/app.py +47 -1
  11. package/src/qingflow_mcp/cli/commands/builder.py +7 -0
  12. package/src/qingflow_mcp/cli/commands/exports.py +111 -0
  13. package/src/qingflow_mcp/cli/commands/record.py +20 -0
  14. package/src/qingflow_mcp/cli/commands/task.py +644 -22
  15. package/src/qingflow_mcp/cli/commands/workspace.py +49 -50
  16. package/src/qingflow_mcp/cli/context.py +3 -0
  17. package/src/qingflow_mcp/cli/formatters.py +139 -4
  18. package/src/qingflow_mcp/cli/interaction.py +72 -0
  19. package/src/qingflow_mcp/cli/main.py +2 -0
  20. package/src/qingflow_mcp/cli/terminal_ui.py +55 -9
  21. package/src/qingflow_mcp/errors.py +2 -2
  22. package/src/qingflow_mcp/export_store.py +14 -0
  23. package/src/qingflow_mcp/public_surface.py +6 -0
  24. package/src/qingflow_mcp/response_trim.py +40 -1
  25. package/src/qingflow_mcp/server.py +22 -0
  26. package/src/qingflow_mcp/server_app_builder.py +4 -0
  27. package/src/qingflow_mcp/server_app_user.py +104 -8
  28. package/src/qingflow_mcp/session_store.py +57 -6
  29. package/src/qingflow_mcp/tools/ai_builder_tools.py +59 -16
  30. package/src/qingflow_mcp/tools/auth_tools.py +26 -0
  31. package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
  32. package/src/qingflow_mcp/tools/export_tools.py +1565 -0
  33. package/src/qingflow_mcp/tools/import_tools.py +42 -2
  34. package/src/qingflow_mcp/tools/record_tools.py +551 -16
  35. package/src/qingflow_mcp/tools/resource_read_tools.py +40 -1
  36. package/src/qingflow_mcp/tools/task_context_tools.py +26 -8
@@ -447,6 +447,30 @@ def _trim_record_list(payload: JSONObject) -> None:
447
447
  payload["data"] = compact
448
448
 
449
449
 
450
+ def _trim_record_access(payload: JSONObject) -> None:
451
+ compact: dict[str, Any] = {}
452
+ for key in (
453
+ "ok",
454
+ "status",
455
+ "app_key",
456
+ "view_id",
457
+ "format",
458
+ "row_count",
459
+ "complete",
460
+ "truncated",
461
+ "safe_for_final_conclusion",
462
+ "files",
463
+ "fields",
464
+ "warnings",
465
+ "verification",
466
+ ):
467
+ value = payload.get(key)
468
+ if value is not None:
469
+ compact[key] = value
470
+ payload.clear()
471
+ payload.update(compact)
472
+
473
+
450
474
  def _trim_record_analyze(payload: JSONObject) -> None:
451
475
  summary: dict[str, Any] = {}
452
476
  completeness = payload.get("completeness")
@@ -666,7 +690,6 @@ def _trim_import_status_payload(payload: JSONObject) -> None:
666
690
  "total_rows",
667
691
  "success_rows",
668
692
  "failed_rows",
669
- "error_file_urls",
670
693
  "operate_time",
671
694
  "operate_user",
672
695
  "verification",
@@ -674,6 +697,10 @@ def _trim_import_status_payload(payload: JSONObject) -> None:
674
697
  payload.pop(key, None)
675
698
 
676
699
 
700
+ def _trim_export_payload(payload: JSONObject) -> None:
701
+ payload.pop("backend_export_id", None)
702
+
703
+
677
704
  def _summarize_import_issues(issues: list[Any]) -> dict[str, Any]:
678
705
  total = 0
679
706
  error_count = 0
@@ -756,6 +783,16 @@ _register_policy(
756
783
  ),
757
784
  _trim_import_schema,
758
785
  )
786
+ _register_policy(
787
+ (USER_DOMAIN,),
788
+ (
789
+ "record_export_start",
790
+ "record_export_status_get",
791
+ "record_export_get",
792
+ "record_export_direct",
793
+ ),
794
+ _trim_export_payload,
795
+ )
759
796
  _register_policy(
760
797
  (USER_DOMAIN,),
761
798
  (
@@ -770,6 +807,7 @@ _register_policy(
770
807
  _register_policy((USER_DOMAIN,), ("record_insert", "record_update"), _trim_record_write)
771
808
  _register_policy((USER_DOMAIN,), ("record_get",), _trim_record_get)
772
809
  _register_policy((USER_DOMAIN,), ("record_list",), _trim_record_list)
810
+ _register_policy((USER_DOMAIN,), ("record_access",), _trim_record_access)
773
811
  _register_policy((USER_DOMAIN,), ("record_analyze",), _trim_record_analyze)
774
812
  _register_policy((USER_DOMAIN,), ("record_code_block_run",), _trim_code_block_run)
775
813
  _register_policy((USER_DOMAIN,), ("task_list",), _trim_task_list)
@@ -818,6 +856,7 @@ _register_policy(
818
856
  "role_create",
819
857
  "app_release_edit_lock_if_mine",
820
858
  "app_resolve",
859
+ "button_style_catalog_get",
821
860
  "app_custom_button_list",
822
861
  "app_custom_button_get",
823
862
  "app_custom_button_create",
@@ -10,6 +10,7 @@ from .tools.app_tools import AppTools
10
10
  from .tools.auth_tools import AuthTools
11
11
  from .tools.code_block_tools import CodeBlockTools
12
12
  from .tools.feedback_tools import FeedbackTools
13
+ from .tools.export_tools import ExportTools
13
14
  from .tools.file_tools import FileTools
14
15
  from .tools.import_tools import ImportTools
15
16
  from .tools.package_tools import PackageTools
@@ -50,6 +51,7 @@ All resource tools operate with the logged-in user's Qingflow permissions.
50
51
  If `app_key` is unknown, use `app_list` or `app_search` first.
51
52
  If the app is known but the data range is not, use `app_get` first and choose from `accessible_views`.
52
53
  If an accessible view has `analysis_supported=false`, do not use it for `record_list` or `record_analyze`. `boardView` and `ganttView` are special UI views, not list/analyze targets.
54
+ `view_get(view_id=...)` also returns `export_capability`; it only means there is a supported export route, not that export permission has been verified.
53
55
 
54
56
  ## Schema-First Rule
55
57
 
@@ -147,6 +149,25 @@ Use `record_code_block_run` when the user wants to execute a form code-block fie
147
149
  - Do not modify user-uploaded files unless the user explicitly authorizes repair.
148
150
  - If repair is authorized, keep the original file and repair a copy, then run `record_import_verify` again before `record_import_start`.
149
151
 
152
+ ## Export Path
153
+
154
+ `view_get -> record_export_start -> record_export_status_get -> record_export_get`
155
+
156
+ - `record_export_direct` is the one-shot export path that starts the export, waits for completion, downloads locally, and still returns remote download links.
157
+ - Export v1 supports record views only and follows the same public `view_id` semantics as `record_list` (`system:*` and `custom:*`).
158
+ - `record_export_start` / `record_export_direct` support frontend-like row selection:
159
+ - omit `record_ids` to export all rows in the selected view
160
+ - pass `record_ids` to export selected rows only
161
+ - `record_export_start` / `record_export_direct` also support internal query selection:
162
+ - pass `where` to resolve matching `record_id` values first
163
+ - pass `order_by` to keep the internal query and export row order aligned with `record_list`
164
+ - then run native export as selected rows
165
+ - `where/order_by` and `record_ids` are mutually exclusive
166
+ - `record_export_start` / `record_export_direct` also support frontend-like column selection:
167
+ - omit `columns` to export all current-view fields
168
+ - pass `columns` to export only selected fields, preserving the provided order
169
+ - `include_workflow_log=true` maps to the native workflow-log export switch.
170
+
150
171
  ## Task Workflow Path
151
172
 
152
173
  `task_list -> task_get -> task_action_execute`
@@ -190,6 +211,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
190
211
  WorkspaceTools(sessions, backend).register(server)
191
212
  FileTools(sessions, backend).register(server)
192
213
  ImportTools(sessions, backend).register(server)
214
+ ExportTools(sessions, backend).register(server)
193
215
  CodeBlockTools(sessions, backend).register(server)
194
216
  TaskContextTools(sessions, backend).register(server)
195
217
  RoleTools(sessions, backend).register(server)
@@ -290,6 +290,10 @@ def build_builder_server() -> FastMCP:
290
290
  )
291
291
  return ai_builder.app_resolve(profile=profile, app_key=app_key, app_name=app_name, package_id=package_id)
292
292
 
293
+ @server.tool()
294
+ def button_style_catalog_get(profile: str = DEFAULT_PROFILE) -> dict:
295
+ return ai_builder.button_style_catalog_get(profile=profile)
296
+
293
297
  @server.tool()
294
298
  def app_custom_button_list(profile: str = DEFAULT_PROFILE, app_key: str = "") -> dict:
295
299
  return ai_builder.app_custom_button_list(profile=profile, app_key=app_key)
@@ -13,6 +13,7 @@ from .tools.app_tools import AppTools
13
13
  from .tools.auth_tools import AuthTools
14
14
  from .tools.code_block_tools import CodeBlockTools
15
15
  from .tools.directory_tools import DirectoryTools
16
+ from .tools.export_tools import ExportTools
16
17
  from .tools.feedback_tools import FeedbackTools
17
18
  from .tools.file_tools import FileTools
18
19
  from .tools.import_tools import ImportTools
@@ -32,7 +33,8 @@ def build_user_server() -> FastMCP:
32
33
 
33
34
  If `app_key` is unknown, use `app_list` or `app_search` first.
34
35
  If the app is known but the data range is not, use `app_get` first and choose from `accessible_views`.
35
- If an accessible view has `analysis_supported=false`, do not use it for `record_list` or `record_analyze`. `boardView` and `ganttView` are special UI views, not list/analyze targets.
36
+ If an accessible view has `analysis_supported=false`, do not use it for `record_access`, `record_list`, or `record_analyze`. `boardView` and `ganttView` are special UI views, not data-access targets.
37
+ `view_get(view_id=...)` also returns `export_capability`; it only means there is a supported export route, not that export permission has been verified.
36
38
 
37
39
  ## Shared Helper
38
40
 
@@ -47,7 +49,7 @@ If an accessible view has `analysis_supported=false`, do not use it for `record_
47
49
  Call `record_insert_schema_get` before `record_insert`.
48
50
  Call `record_update_schema_get` before `record_update`.
49
51
  Call `record_code_block_schema_get` before `record_code_block_run`.
50
- Call `app_get` first when the data range is unclear, then use `record_browse_schema_get(view_id=...)` before `record_list`, `record_get`, or `record_analyze`.
52
+ Call `app_get` first when the data range is unclear, then use `record_browse_schema_get(view_id=...)` before `record_access`, `record_list`, `record_get`, or `record_analyze`.
51
53
  Call `record_import_schema_get` when the import field mapping is unclear before template download or verify.
52
54
 
53
55
  - All `field_id` values must come from the schema response.
@@ -68,16 +70,18 @@ Inside `optional_fields`, any field with `may_become_required=true` is still wri
68
70
 
69
71
  ## Analytics Path
70
72
 
71
- `app_get -> record_browse_schema_get(view_id=...) -> record_analyze`
73
+ `app_get -> record_browse_schema_get(view_id=...) -> record_access -> Python`
72
74
 
73
75
  Prefer `view_id` entries from `accessible_views` where `analysis_supported=true`.
74
76
 
75
- Use this DSL shape:
77
+ Use `record_access` to write local CSV shard files, then use Python to compute counts, rankings, ratios, trends, and final conclusions. `record_access` does not return bulk `items`; read `files[].local_path`.
78
+ Use `chart_get` only when the user provides a report URL / chart_id or explicitly asks to read an existing report. Do not use QingBI as the default analysis route.
76
79
 
77
- - `dimensions`: `{{field_id, alias, bucket}}`
78
- - `metrics`: `{{op, field_id, alias}}`
79
- - `filters`: `{{field_id, op, value}}`
80
- - `sort`: `{{by, order}}`
80
+ Use this data-access DSL shape:
81
+
82
+ - `columns`: `[{{field_id}}]`
83
+ - `where`: `[{{field_id, op, value}}]`
84
+ - `order_by`: `[{{field_id, direction}}]`
81
85
 
82
86
  Important key rules:
83
87
 
@@ -87,6 +91,8 @@ Important key rules:
87
91
  - Do **not** use `aggregation`
88
92
  - Do **not** use `operator`
89
93
 
94
+ `record_analyze` is a lightweight non-default statistics helper. Use it only when a compact grouped result is clearly sufficient. `record_list` is for browsing and sample checks, not final analysis conclusions.
95
+
90
96
  Analysis answers must include concrete numbers. When applicable, include percentages based on the returned totals.
91
97
 
92
98
  ## Record CRUD Path
@@ -142,6 +148,28 @@ Use `record_code_block_run` when the user wants to execute a form code-block fie
142
148
  - Do not modify user-uploaded files unless the user explicitly authorizes repair.
143
149
  - If repair is authorized, keep the original file and repair a copy, then run `record_import_verify` again before `record_import_start`.
144
150
 
151
+ ## Export Path
152
+
153
+ Use export only when the user explicitly asks to export/download/generate an Excel or export file.
154
+
155
+ `view_get -> record_export_start -> record_export_status_get -> record_export_get`
156
+
157
+ - `record_export_direct` is the one-shot path that starts export, waits, downloads locally, and still returns remote download links.
158
+ - Do not use `record_export_direct` as the default analysis path; use `record_access -> Python` instead.
159
+ - Export v1 supports record views only and follows the same public `view_id` semantics as `record_list`.
160
+ - `record_export_start` / `record_export_direct` support frontend-like row selection:
161
+ - omit `record_ids` to export all rows in the selected view
162
+ - pass `record_ids` to export selected rows only
163
+ - `record_export_start` / `record_export_direct` also support internal query selection:
164
+ - pass `where` to resolve matching `record_id` values first
165
+ - pass `order_by` to keep the internal query and export row order aligned with `record_list`
166
+ - then run native export as selected rows
167
+ - `where/order_by` and `record_ids` are mutually exclusive
168
+ - `record_export_start` / `record_export_direct` also support frontend-like column selection:
169
+ - omit `columns` to export all current-view fields
170
+ - pass `columns` to export only selected fields, preserving the provided order
171
+ - `include_workflow_log=true` maps to the native workflow-log export switch.
172
+
145
173
  ## Task Workflow Path
146
174
 
147
175
  `task_list -> task_get -> task_action_execute`
@@ -188,6 +216,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
188
216
  workspace = wrap_trimmed_methods(WorkspaceTools(sessions, backend), USER_SERVER_METHOD_MAP)
189
217
  file_tools = wrap_trimmed_methods(FileTools(sessions, backend), USER_SERVER_METHOD_MAP)
190
218
  imports = wrap_trimmed_methods(ImportTools(sessions, backend), USER_SERVER_METHOD_MAP)
219
+ exports = wrap_trimmed_methods(ExportTools(sessions, backend), USER_SERVER_METHOD_MAP)
191
220
  resources = wrap_trimmed_methods(ResourceReadTools(sessions, backend), USER_SERVER_METHOD_MAP)
192
221
  feedback = FeedbackTools(backend, mcp_side="App User MCP")
193
222
  code_block_tools = wrap_trimmed_methods(CodeBlockTools(sessions, backend), USER_SERVER_METHOD_MAP)
@@ -218,6 +247,73 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
218
247
  def auth_logout(profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict:
219
248
  return auth.auth_logout(profile=profile, forget_persisted=forget_persisted)
220
249
 
250
+ @server.tool()
251
+ def record_export_start(
252
+ profile: str = DEFAULT_PROFILE,
253
+ app_key: str = "",
254
+ view_id: str = "system:all",
255
+ columns: list[dict | int] | None = None,
256
+ where: list[dict] | None = None,
257
+ order_by: list[dict] | None = None,
258
+ record_ids: list[str | int] | None = None,
259
+ include_workflow_log: bool = False,
260
+ ) -> dict:
261
+ return exports.record_export_start(
262
+ profile=profile,
263
+ app_key=app_key,
264
+ view_id=view_id,
265
+ columns=columns or [],
266
+ where=where or [],
267
+ order_by=order_by or [],
268
+ record_ids=record_ids or [],
269
+ include_workflow_log=include_workflow_log,
270
+ )
271
+
272
+ @server.tool()
273
+ def record_export_status_get(
274
+ profile: str = DEFAULT_PROFILE,
275
+ export_handle: str = "",
276
+ ) -> dict:
277
+ return exports.record_export_status_get(profile=profile, export_handle=export_handle)
278
+
279
+ @server.tool()
280
+ def record_export_get(
281
+ profile: str = DEFAULT_PROFILE,
282
+ export_handle: str = "",
283
+ download_to_path: str | None = None,
284
+ ) -> dict:
285
+ return exports.record_export_get(
286
+ profile=profile,
287
+ export_handle=export_handle,
288
+ download_to_path=download_to_path,
289
+ )
290
+
291
+ @server.tool()
292
+ def record_export_direct(
293
+ profile: str = DEFAULT_PROFILE,
294
+ app_key: str = "",
295
+ view_id: str = "system:all",
296
+ columns: list[dict | int] | None = None,
297
+ where: list[dict] | None = None,
298
+ order_by: list[dict] | None = None,
299
+ record_ids: list[str | int] | None = None,
300
+ include_workflow_log: bool = False,
301
+ download_to_path: str | None = None,
302
+ wait_timeout_seconds: float | None = None,
303
+ ) -> dict:
304
+ return exports.record_export_direct(
305
+ profile=profile,
306
+ app_key=app_key,
307
+ view_id=view_id,
308
+ columns=columns or [],
309
+ where=where or [],
310
+ order_by=order_by or [],
311
+ record_ids=record_ids or [],
312
+ include_workflow_log=include_workflow_log,
313
+ download_to_path=download_to_path,
314
+ wait_timeout_seconds=wait_timeout_seconds,
315
+ )
316
+
221
317
  @server.tool()
222
318
  def workspace_list(
223
319
  profile: str = DEFAULT_PROFILE,
@@ -2,8 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
+ from collections.abc import Callable
5
6
  from dataclasses import asdict, dataclass
6
- from datetime import datetime, timezone
7
+ from datetime import datetime, timedelta, timezone
7
8
  from pathlib import Path
8
9
 
9
10
  try:
@@ -82,6 +83,11 @@ class SessionStore:
82
83
  self._keyring = keyring_backend if keyring_backend is not None else keyring
83
84
  self._memory_sessions: dict[str, BackendSession] = {}
84
85
  self._logged_out_profiles: set[str] = set()
86
+ self._profile_refresher: Callable[[str, SessionProfile | None], bool] | None = None
87
+ self._refreshing_profiles: set[str] = set()
88
+
89
+ def set_profile_refresher(self, refresher: Callable[[str, SessionProfile | None], bool] | None) -> None:
90
+ self._profile_refresher = refresher
85
91
 
86
92
  def save_session(
87
93
  self,
@@ -148,9 +154,14 @@ class SessionStore:
148
154
  def get_profile(self, profile: str) -> SessionProfile | None:
149
155
  payload = self._load_profiles()
150
156
  raw_profile = payload.get("profiles", {}).get(profile)
151
- if not raw_profile:
152
- return None
153
- return SessionProfile.from_dict(raw_profile)
157
+ session_profile = SessionProfile.from_dict(raw_profile) if isinstance(raw_profile, dict) else None
158
+ if profile in self._refreshing_profiles:
159
+ return session_profile
160
+ if self._should_refresh_profile(payload, session_profile) and self._refresh_profile(profile, session_profile):
161
+ payload = self._load_profiles()
162
+ raw_profile = payload.get("profiles", {}).get(profile)
163
+ session_profile = SessionProfile.from_dict(raw_profile) if isinstance(raw_profile, dict) else None
164
+ return session_profile
154
165
 
155
166
  def get_backend_session(self, profile: str) -> BackendSession | None:
156
167
  if profile in self._logged_out_profiles:
@@ -283,8 +294,48 @@ class SessionStore:
283
294
  def _load_profiles(self) -> JSONObject:
284
295
  if not self._profiles_path.exists():
285
296
  return {"profiles": {}}
286
- with self._profiles_path.open("r", encoding="utf-8") as handle:
287
- return json.load(handle)
297
+ try:
298
+ with self._profiles_path.open("r", encoding="utf-8") as handle:
299
+ payload = json.load(handle)
300
+ except (OSError, json.JSONDecodeError):
301
+ return {"profiles": {}}
302
+ if not isinstance(payload, dict):
303
+ return {"profiles": {}}
304
+ profiles = payload.get("profiles")
305
+ if not isinstance(profiles, dict):
306
+ payload["profiles"] = {}
307
+ return payload
308
+
309
+ def _should_refresh_profile(self, payload: JSONObject, session_profile: SessionProfile | None) -> bool:
310
+ if self._profile_refresher is None:
311
+ return False
312
+ profiles = payload.get("profiles")
313
+ if not isinstance(profiles, dict) or not profiles:
314
+ return True
315
+ if session_profile is None:
316
+ return True
317
+ return self._is_profile_stale(session_profile)
318
+
319
+ def _refresh_profile(self, profile: str, session_profile: SessionProfile | None) -> bool:
320
+ if self._profile_refresher is None:
321
+ return False
322
+ self._refreshing_profiles.add(profile)
323
+ try:
324
+ return bool(self._profile_refresher(profile, session_profile))
325
+ except Exception:
326
+ return False
327
+ finally:
328
+ self._refreshing_profiles.discard(profile)
329
+
330
+ def _is_profile_stale(self, session_profile: SessionProfile) -> bool:
331
+ timestamp_text = session_profile.updated_at or session_profile.created_at
332
+ try:
333
+ timestamp = datetime.fromisoformat(timestamp_text)
334
+ except (TypeError, ValueError):
335
+ return True
336
+ if timestamp.tzinfo is None:
337
+ timestamp = timestamp.replace(tzinfo=timezone.utc)
338
+ return datetime.now(timezone.utc) - timestamp > timedelta(days=7)
288
339
 
289
340
  def _save_profiles(self, payload: JSONObject) -> None:
290
341
  self._profiles_path.parent.mkdir(parents=True, exist_ok=True)
@@ -6,6 +6,12 @@ import time
6
6
 
7
7
  from pydantic import ValidationError
8
8
 
9
+ from ..builder_facade.button_style_catalog import (
10
+ BUTTON_BACKGROUND_COLORS,
11
+ BUTTON_ICONS,
12
+ BUTTON_STYLE_PRESETS,
13
+ BUTTON_TEXT_COLORS,
14
+ )
9
15
  from ..public_surface import public_builder_contract_tool_names
10
16
  from ..config import DEFAULT_PROFILE
11
17
  from ..errors import QingflowApiError
@@ -209,6 +215,10 @@ class AiBuilderTools(ToolBase):
209
215
  )
210
216
  return self.app_resolve(profile=profile, app_key=app_key, app_name=app_name, package_id=package_id)
211
217
 
218
+ @mcp.tool()
219
+ def button_style_catalog_get(profile: str = DEFAULT_PROFILE) -> JSONObject:
220
+ return self.button_style_catalog_get(profile=profile)
221
+
212
222
  @mcp.tool()
213
223
  def app_custom_button_list(profile: str = DEFAULT_PROFILE, app_key: str = "") -> JSONObject:
214
224
  return self.app_custom_button_list(profile=profile, app_key=app_key)
@@ -846,6 +856,17 @@ class AiBuilderTools(ToolBase):
846
856
  suggested_next_call={"tool_name": "app_resolve", "arguments": {"profile": profile, **normalized_args}},
847
857
  ))
848
858
 
859
+ @tool_cn_name("按钮样式目录")
860
+ def button_style_catalog_get(self, *, profile: str) -> JSONObject:
861
+ """执行按钮样式相关逻辑。"""
862
+ normalized_args: dict[str, object] = {}
863
+ return _safe_tool_call(
864
+ lambda: self._facade.button_style_catalog_get(profile=profile),
865
+ error_code="BUTTON_STYLE_CATALOG_GET_FAILED",
866
+ normalized_args=normalized_args,
867
+ suggested_next_call={"tool_name": "button_style_catalog_get", "arguments": {"profile": profile}},
868
+ )
869
+
849
870
  @tool_cn_name("应用按钮列表")
850
871
  def app_custom_button_list(self, *, profile: str, app_key: str) -> JSONObject:
851
872
  """执行应用相关逻辑。"""
@@ -885,9 +906,8 @@ class AiBuilderTools(ToolBase):
885
906
  "app_key": app_key,
886
907
  "payload": {
887
908
  "button_text": "新增记录",
888
- "background_color": "#FFFFFF",
889
- "text_color": "#494F57",
890
- "button_icon": "ex-add-outlined",
909
+ "style_preset": "primary_blue",
910
+ "button_icon": "ex-plus-circle",
891
911
  "trigger_action": "addData",
892
912
  "trigger_add_data_config": {"related_app_key": "TARGET_APP_KEY", "que_relation": []},
893
913
  },
@@ -920,9 +940,8 @@ class AiBuilderTools(ToolBase):
920
940
  "button_id": button_id,
921
941
  "payload": {
922
942
  "button_text": "新增记录",
923
- "background_color": "#FFFFFF",
924
- "text_color": "#494F57",
925
- "button_icon": "ex-add-outlined",
943
+ "style_preset": "neutral_outline",
944
+ "button_icon": "ex-edit",
926
945
  "trigger_action": "link",
927
946
  "trigger_link_url": "https://example.com",
928
947
  },
@@ -2535,6 +2554,24 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2535
2554
  "lock_owner_name": "当前用户",
2536
2555
  },
2537
2556
  },
2557
+ "button_style_catalog_get": {
2558
+ "allowed_keys": [],
2559
+ "aliases": {},
2560
+ "allowed_values": {
2561
+ "preset.key": [item["key"] for item in BUTTON_STYLE_PRESETS],
2562
+ "icon": list(BUTTON_ICONS),
2563
+ "background_color": list(BUTTON_BACKGROUND_COLORS),
2564
+ "text_color": list(BUTTON_TEXT_COLORS),
2565
+ },
2566
+ "execution_notes": [
2567
+ "use this read-only tool before button writes when an agent needs a supported icon or color choice",
2568
+ "current frontend only supports template icons and template colors from this catalog",
2569
+ "text/icon color is unified through text_color; there is no separate icon_color",
2570
+ ],
2571
+ "minimal_example": {
2572
+ "profile": "default",
2573
+ },
2574
+ },
2538
2575
  "app_custom_button_list": {
2539
2576
  "allowed_keys": ["app_key"],
2540
2577
  "aliases": {},
@@ -2558,10 +2595,10 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2558
2595
  "allowed_keys": ["app_key", "payload"],
2559
2596
  "aliases": {
2560
2597
  "payload.buttonText": "payload.button_text",
2598
+ "payload.stylePreset": "payload.style_preset",
2561
2599
  "payload.backgroundColor": "payload.background_color",
2562
2600
  "payload.textColor": "payload.text_color",
2563
2601
  "payload.buttonIcon": "payload.button_icon",
2564
- "payload.iconColor": "payload.icon_color",
2565
2602
  "payload.triggerAction": "payload.trigger_action",
2566
2603
  "payload.triggerLinkUrl": "payload.trigger_link_url",
2567
2604
  "payload.triggerAddDataConfig": "payload.trigger_add_data_config",
@@ -2571,10 +2608,15 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2571
2608
  },
2572
2609
  "allowed_values": {
2573
2610
  "payload.trigger_action": [member.value for member in PublicButtonTriggerAction],
2611
+ "payload.style_preset": [item["key"] for item in BUTTON_STYLE_PRESETS],
2612
+ "payload.button_icon": list(BUTTON_ICONS),
2613
+ "payload.background_color": list(BUTTON_BACKGROUND_COLORS),
2614
+ "payload.text_color": list(BUTTON_TEXT_COLORS),
2574
2615
  },
2575
2616
  "execution_notes": [
2576
2617
  "custom button writes now auto-publish the current app draft as a fixed closing step",
2577
2618
  "background_color and text_color cannot both be white",
2619
+ "payload accepts either style_preset + optional button_icon, or explicit button_icon/background_color/text_color from button_style_catalog_get",
2578
2620
  "for addData buttons, put field mappings in payload.trigger_add_data_config.que_relation",
2579
2621
  ],
2580
2622
  "minimal_example": {
@@ -2582,10 +2624,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2582
2624
  "app_key": "APP_KEY",
2583
2625
  "payload": {
2584
2626
  "button_text": "新增记录",
2585
- "background_color": "#FFFFFF",
2586
- "text_color": "#494F57",
2587
- "button_icon": "ex-add-outlined",
2588
- "icon_color": "#494F57",
2627
+ "style_preset": "primary_blue",
2628
+ "button_icon": "ex-plus-circle",
2589
2629
  "trigger_action": "link",
2590
2630
  "trigger_link_url": "https://example.com",
2591
2631
  },
@@ -2596,10 +2636,10 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2596
2636
  "aliases": {
2597
2637
  "buttonId": "button_id",
2598
2638
  "payload.buttonText": "payload.button_text",
2639
+ "payload.stylePreset": "payload.style_preset",
2599
2640
  "payload.backgroundColor": "payload.background_color",
2600
2641
  "payload.textColor": "payload.text_color",
2601
2642
  "payload.buttonIcon": "payload.button_icon",
2602
- "payload.iconColor": "payload.icon_color",
2603
2643
  "payload.triggerAction": "payload.trigger_action",
2604
2644
  "payload.triggerLinkUrl": "payload.trigger_link_url",
2605
2645
  "payload.triggerAddDataConfig": "payload.trigger_add_data_config",
@@ -2609,10 +2649,15 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2609
2649
  },
2610
2650
  "allowed_values": {
2611
2651
  "payload.trigger_action": [member.value for member in PublicButtonTriggerAction],
2652
+ "payload.style_preset": [item["key"] for item in BUTTON_STYLE_PRESETS],
2653
+ "payload.button_icon": list(BUTTON_ICONS),
2654
+ "payload.background_color": list(BUTTON_BACKGROUND_COLORS),
2655
+ "payload.text_color": list(BUTTON_TEXT_COLORS),
2612
2656
  },
2613
2657
  "execution_notes": [
2614
2658
  "custom button writes now auto-publish the current app draft as a fixed closing step",
2615
2659
  "background_color and text_color cannot both be white",
2660
+ "payload accepts either style_preset + optional button_icon, or explicit button_icon/background_color/text_color from button_style_catalog_get",
2616
2661
  "for addData buttons, put field mappings in payload.trigger_add_data_config.que_relation",
2617
2662
  ],
2618
2663
  "minimal_example": {
@@ -2621,10 +2666,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2621
2666
  "button_id": 1001,
2622
2667
  "payload": {
2623
2668
  "button_text": "查看详情",
2624
- "background_color": "#FFFFFF",
2625
- "text_color": "#494F57",
2626
- "button_icon": "ex-link-outlined",
2627
- "icon_color": "#494F57",
2669
+ "style_preset": "neutral_outline",
2670
+ "button_icon": "ex-edit",
2628
2671
  "trigger_action": "link",
2629
2672
  "trigger_link_url": "https://example.com/detail",
2630
2673
  },
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  from typing import Any
5
+ import os
5
6
 
6
7
  from mcp.server.fastmcp import FastMCP
7
8
 
@@ -30,6 +31,7 @@ class AuthTools(ToolBase):
30
31
  def __init__(self, sessions: SessionStore, backend) -> None:
31
32
  """执行内部辅助逻辑。"""
32
33
  super().__init__(sessions, backend)
34
+ self.sessions.set_profile_refresher(self._refresh_profile_via_credential)
33
35
 
34
36
  def register(self, mcp: FastMCP) -> None:
35
37
  """注册当前工具到 MCP 服务。"""
@@ -314,8 +316,12 @@ class AuthTools(ToolBase):
314
316
  mcporter_context = self._read_mcporter_qingflow_context()
315
317
  if not normalized_base_url:
316
318
  normalized_base_url = self._normalize_text(mcporter_context.get("base_url"))
319
+ if not normalized_base_url:
320
+ normalized_base_url = os.getenv("QINGFLOW_BASE_URL")
317
321
  if not normalized_credential:
318
322
  normalized_credential = self._normalize_text(mcporter_context.get("credential"))
323
+ if not normalized_credential:
324
+ normalized_credential = os.getenv("QINGFLOW_CREDENTIAL")
319
325
  return normalized_base_url, normalized_credential or ""
320
326
 
321
327
  def _read_mcporter_qingflow_context(self) -> dict[str, str]:
@@ -344,6 +350,26 @@ class AuthTools(ToolBase):
344
350
  "credential": str(credential or "").strip(),
345
351
  }
346
352
 
353
+ def _refresh_profile_via_credential(
354
+ self,
355
+ profile: str,
356
+ session_profile,
357
+ ) -> bool:
358
+ backend_session = self.sessions.get_backend_session(profile) if session_profile is not None else None
359
+ base_url = session_profile.base_url if session_profile is not None else None
360
+ credential = backend_session.credential if backend_session is not None else None
361
+ persist = bool(session_profile.persisted) if session_profile is not None else False
362
+ try:
363
+ self.auth_use_credential(
364
+ profile=profile,
365
+ base_url=base_url,
366
+ credential=credential,
367
+ persist=persist,
368
+ )
369
+ except (QingflowApiError, RuntimeError):
370
+ return False
371
+ return True
372
+
347
373
  @tool_cn_name("我的身份")
348
374
  def auth_whoami(self, *, profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
349
375
  """执行认证与会话相关逻辑。"""
@@ -169,7 +169,6 @@ class CustomButtonTools(ToolBase):
169
169
  "button_id": item.get("buttonId"),
170
170
  "button_text": item.get("buttonText"),
171
171
  "button_icon": item.get("buttonIcon"),
172
- "icon_color": item.get("iconColor"),
173
172
  "background_color": item.get("backgroundColor"),
174
173
  "text_color": item.get("textColor"),
175
174
  "creator_user_info": {
@@ -189,7 +188,6 @@ class CustomButtonTools(ToolBase):
189
188
  "button_id": item.get("buttonId"),
190
189
  "button_text": item.get("buttonText"),
191
190
  "button_icon": item.get("buttonIcon"),
192
- "icon_color": item.get("iconColor"),
193
191
  "background_color": item.get("backgroundColor"),
194
192
  "text_color": item.get("textColor"),
195
193
  "trigger_action": item.get("triggerAction"),