@qingflow-tech/qingflow-app-user-mcp 1.0.2 → 1.0.4

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 (56) hide show
  1. package/README.md +2 -2
  2. package/docs/local-agent-install.md +9 -3
  3. package/npm/lib/runtime.mjs +10 -3
  4. package/package.json +1 -1
  5. package/pyproject.toml +1 -1
  6. package/skills/qingflow-app-user/SKILL.md +21 -12
  7. package/skills/qingflow-app-user/references/data-gotchas.md +1 -1
  8. package/skills/qingflow-app-user/references/public-surface-sync.md +70 -0
  9. package/skills/qingflow-app-user/references/record-patterns.md +1 -1
  10. package/skills/qingflow-record-analysis/SKILL.md +44 -2
  11. package/skills/qingflow-record-insert/SKILL.md +3 -0
  12. package/skills/qingflow-record-update/SKILL.md +3 -0
  13. package/skills/qingflow-task-ops/SKILL.md +31 -10
  14. package/src/qingflow_mcp/__init__.py +33 -1
  15. package/src/qingflow_mcp/backend_client.py +109 -0
  16. package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
  17. package/src/qingflow_mcp/builder_facade/models.py +58 -9
  18. package/src/qingflow_mcp/builder_facade/service.py +1711 -240
  19. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  20. package/src/qingflow_mcp/cli/commands/app.py +47 -1
  21. package/src/qingflow_mcp/cli/commands/auth.py +63 -0
  22. package/src/qingflow_mcp/cli/commands/builder.py +11 -3
  23. package/src/qingflow_mcp/cli/commands/exports.py +111 -0
  24. package/src/qingflow_mcp/cli/commands/record.py +5 -5
  25. package/src/qingflow_mcp/cli/commands/task.py +701 -27
  26. package/src/qingflow_mcp/cli/commands/workspace.py +84 -0
  27. package/src/qingflow_mcp/cli/context.py +3 -0
  28. package/src/qingflow_mcp/cli/formatters.py +424 -50
  29. package/src/qingflow_mcp/cli/interaction.py +72 -0
  30. package/src/qingflow_mcp/cli/main.py +11 -1
  31. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  32. package/src/qingflow_mcp/cli/terminal_ui.py +218 -0
  33. package/src/qingflow_mcp/config.py +1 -1
  34. package/src/qingflow_mcp/errors.py +4 -4
  35. package/src/qingflow_mcp/export_store.py +14 -0
  36. package/src/qingflow_mcp/id_utils.py +49 -0
  37. package/src/qingflow_mcp/public_surface.py +16 -1
  38. package/src/qingflow_mcp/response_trim.py +394 -9
  39. package/src/qingflow_mcp/server.py +26 -0
  40. package/src/qingflow_mcp/server_app_builder.py +15 -1
  41. package/src/qingflow_mcp/server_app_user.py +113 -0
  42. package/src/qingflow_mcp/session_store.py +126 -21
  43. package/src/qingflow_mcp/solution/compiler/form_compiler.py +2 -2
  44. package/src/qingflow_mcp/solution/executor.py +2 -2
  45. package/src/qingflow_mcp/tools/ai_builder_tools.py +107 -34
  46. package/src/qingflow_mcp/tools/app_tools.py +1 -0
  47. package/src/qingflow_mcp/tools/auth_tools.py +243 -9
  48. package/src/qingflow_mcp/tools/base.py +6 -2
  49. package/src/qingflow_mcp/tools/code_block_tools.py +2 -2
  50. package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
  51. package/src/qingflow_mcp/tools/export_tools.py +1565 -0
  52. package/src/qingflow_mcp/tools/import_tools.py +78 -4
  53. package/src/qingflow_mcp/tools/record_tools.py +551 -165
  54. package/src/qingflow_mcp/tools/resource_read_tools.py +154 -33
  55. package/src/qingflow_mcp/tools/task_context_tools.py +917 -141
  56. package/src/qingflow_mcp/tools/workspace_tools.py +141 -0
@@ -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
  },
@@ -1873,7 +1892,7 @@ class AiBuilderTools(ToolBase):
1873
1892
  dash_name: str = "",
1874
1893
  package_id: int | None = None,
1875
1894
  publish: bool = True,
1876
- sections: list[JSONObject],
1895
+ sections: list[JSONObject] | None = None,
1877
1896
  visibility: JSONObject | None = None,
1878
1897
  auth: JSONObject | None = None,
1879
1898
  icon: str | None = None,
@@ -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
  },
@@ -2665,6 +2708,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2665
2708
  "field.autoTrigger": "field.auto_trigger",
2666
2709
  "field.customBtnTextStatus": "field.custom_button_text_enabled",
2667
2710
  "field.customBtnText": "field.custom_button_text",
2711
+ "field.subfieldUpdates": "field.subfield_updates",
2668
2712
  },
2669
2713
  "allowed_values": {
2670
2714
  "field.type": [member.value for member in PublicFieldType],
@@ -2730,6 +2774,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2730
2774
  "field.autoTrigger": "field.auto_trigger",
2731
2775
  "field.customBtnTextStatus": "field.custom_button_text_enabled",
2732
2776
  "field.customBtnText": "field.custom_button_text",
2777
+ "field.subfieldUpdates": "field.subfield_updates",
2733
2778
  },
2734
2779
  "allowed_values": {
2735
2780
  "field.type": [member.value for member in PublicFieldType],
@@ -2751,6 +2796,9 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2751
2796
  "relation_mode=multiple maps to referenceConfig.optionalDataNum=0",
2752
2797
  "relation fields now require both display_field and visible_fields in MCP/CLI payloads",
2753
2798
  "if relation target metadata lookup is blocked by 40161/40002/40027, explicit display_field.name and visible_fields[].name let builder degrade verification and still continue schema write",
2799
+ "update_fields[].set.subfield_updates is the safe patch path for editing existing subtable child fields without rebuilding the entire subtable",
2800
+ "subfield_updates only supports safe child overlays: name, required, description, and nested subfield_updates",
2801
+ "set.subfields remains the full replace/rebuild path for a subtable and is higher risk when hidden relation/reference children exist",
2754
2802
  "department fields accept department_scope with mode=all or mode=custom; custom scope requires explicit departments[].dept_id and optional include_sub_departs",
2755
2803
  "q_linker_binding lets you declare request config, dynamic inputs, alias parsing, and target-field bindings in one step; builder writes remoteLookupConfig plus the existing backend relation-default and questionRelations structures",
2756
2804
  "code_block_binding lets you declare inputs, code, alias parsing, and target-field bindings in one step; builder writes codeBlockConfig plus the existing backend relation-default and questionRelations structures",
@@ -2798,6 +2846,26 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2798
2846
  "update_fields": [],
2799
2847
  "remove_fields": [],
2800
2848
  },
2849
+ "subfield_update_example": {
2850
+ "profile": "default",
2851
+ "app_key": "APP_REWARD",
2852
+ "publish": True,
2853
+ "add_fields": [],
2854
+ "update_fields": [
2855
+ {
2856
+ "selector": {"que_id": 441305044},
2857
+ "set": {
2858
+ "subfield_updates": [
2859
+ {
2860
+ "selector": {"que_id": 441305045},
2861
+ "set": {"name": "景品名"},
2862
+ }
2863
+ ]
2864
+ },
2865
+ }
2866
+ ],
2867
+ "remove_fields": [],
2868
+ },
2801
2869
  "department_scope_example": {
2802
2870
  "profile": "default",
2803
2871
  "app_key": "APP_LEAD",
@@ -3149,7 +3217,9 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3149
3217
  "execution_notes": [
3150
3218
  "returns builder-side app configuration summary and editability",
3151
3219
  "use this as the default builder discovery read before fields/layout/views/flow/charts detail reads",
3152
- "editability reflects builder permissions, not end-user data visibility",
3220
+ "editability is route-aware builder capability summary, not end-user data visibility",
3221
+ "can_edit_app_base covers app base-info writes such as app_name, icon, and visibility",
3222
+ "can_edit_form covers form/schema routes only and does not imply app base-info writes",
3153
3223
  "returns normalized app visibility when backend auth is readable",
3154
3224
  ],
3155
3225
  "minimal_example": {
@@ -3164,6 +3234,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3164
3234
  "execution_notes": [
3165
3235
  "returns compact current field configuration for one app",
3166
3236
  "use this before app_schema_apply when you need exact field definitions",
3237
+ "subtable fields include nested subfields using the same compact field shape",
3167
3238
  ],
3168
3239
  "minimal_example": {
3169
3240
  "profile": "default",
@@ -3269,14 +3340,15 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3269
3340
  "chart.filter.operator": [member.value for member in ViewFilterOperator],
3270
3341
  **deepcopy(_VISIBILITY_ALLOWED_VALUES),
3271
3342
  },
3272
- "execution_notes": [
3273
- "app_charts_apply is immediate-live and does not publish",
3274
- "chart matching precedence is chart_id first, then exact unique chart name",
3275
- "when chart names are not unique, supply chart_id instead of guessing by name",
3276
- "successful create results must return a real backend chart_id",
3277
- "upsert_charts[].visibility compiles to QingBI visibleAuth; omit it on updates to preserve the existing visibleAuth",
3278
- *_VISIBILITY_EXECUTION_NOTES,
3279
- ],
3343
+ "execution_notes": [
3344
+ "app_charts_apply is immediate-live and does not publish",
3345
+ "chart matching precedence is chart_id first, then exact unique chart name",
3346
+ "when chart names are not unique, supply chart_id instead of guessing by name",
3347
+ "successful create results must return a real backend chart_id",
3348
+ "upsert_charts[].visibility compiles to QingBI base visibleAuth only",
3349
+ "visibility-only updates keep the existing chart config and do not rewrite rawDataConfigDTO.authInfo",
3350
+ *_VISIBILITY_EXECUTION_NOTES,
3351
+ ],
3280
3352
  "minimal_example": {
3281
3353
  "profile": "default",
3282
3354
  "app_key": "APP_KEY",
@@ -3332,14 +3404,15 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3332
3404
  "dashStyleConfigBO": "dash_style_config",
3333
3405
  },
3334
3406
  "allowed_values": {"section.source_type": ["chart", "view", "grid", "filter", "text", "link"], **deepcopy(_VISIBILITY_ALLOWED_VALUES)},
3335
- "execution_notes": [
3336
- "use exactly one resource mode",
3337
- "update mode: dash_key",
3338
- "create mode: package_id + dash_name",
3339
- "portal_apply uses replace semantics for sections",
3340
- "remove a section by omitting it from the new sections list",
3341
- "package_id is required when creating a new portal",
3342
- "publish=false only guarantees draft and base-info updates; it does not claim live has changed",
3407
+ "execution_notes": [
3408
+ "use exactly one resource mode",
3409
+ "update mode: dash_key",
3410
+ "create mode: package_id + dash_name",
3411
+ "portal_apply uses replace semantics for sections",
3412
+ "when editing an existing portal, sections may be omitted to update only base info such as visibility, icon, or package",
3413
+ "remove a section by omitting it from the new sections list",
3414
+ "package_id is required when creating a new portal",
3415
+ "publish=false only guarantees draft and base-info updates; it does not claim live has changed",
3343
3416
  "chart_ref resolves by chart_id first, then exact unique chart_name",
3344
3417
  "view_ref resolves by view_key first, then exact unique view_name",
3345
3418
  "position.pc/mobile is the canonical portal layout shape",
@@ -729,6 +729,7 @@ class AppTools(ToolBase):
729
729
  tag_ids = item.get("tagIds") if isinstance(item.get("tagIds"), list) else []
730
730
  compact = {
731
731
  "app_key": app_key,
732
+ "app_name": title,
732
733
  "title": title,
733
734
  "form_id": item.get("formId"),
734
735
  "tag_id": package_tag_id,
@@ -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 服务。"""
@@ -93,7 +95,7 @@ class AuthTools(ToolBase):
93
95
  if not normalized_credential:
94
96
  raise_tool_error(
95
97
  QingflowApiError.config_error(
96
- "credential is required or configure /root/.openclaw/workspace/config/mcporter.json "
98
+ "credential is required or configure ~/.openclaw/workspace/config/mcporter.json "
97
99
  "with mcpServers.qingflow.headers.x-qingflow-client-id"
98
100
  )
99
101
  )
@@ -164,6 +166,146 @@ class AuthTools(ToolBase):
164
166
  ),
165
167
  }
166
168
 
169
+ def auth_use_token(
170
+ self,
171
+ *,
172
+ profile: str = DEFAULT_PROFILE,
173
+ base_url: str | None = None,
174
+ qf_version: str | None = None,
175
+ token: str | None = None,
176
+ login_token: str | None = None,
177
+ persist: bool = False,
178
+ user_info: dict[str, Any] | None = None,
179
+ ) -> dict[str, Any]:
180
+ """使用已获得的 Qingflow token 建立本地会话。"""
181
+ normalized_base_url = self._normalize_base_url(base_url)
182
+ normalized_qf_version, qf_version_source = self._resolve_qf_version_input(qf_version)
183
+ normalized_token = self._normalize_text(token)
184
+ normalized_login_token = self._normalize_text(login_token)
185
+ if not normalized_token:
186
+ raise_tool_error(QingflowApiError.config_error("token is required"))
187
+
188
+ resolved_user_info = user_info if isinstance(user_info, dict) else None
189
+ response_qf_version: str | None = None
190
+ if resolved_user_info is None:
191
+ resolved_user_info, response_qf_version = self._fetch_user_info(
192
+ normalized_base_url,
193
+ normalized_token,
194
+ None,
195
+ qf_version=normalized_qf_version,
196
+ qf_version_source=qf_version_source,
197
+ )
198
+
199
+ last_workspace = resolved_user_info.get("lastWsInfo")
200
+ selected_ws_id = self._coerce_positive_int(
201
+ last_workspace.get("wsId") if isinstance(last_workspace, dict) else None
202
+ )
203
+ selected_ws_name = self._normalize_text(
204
+ (last_workspace.get("wsName") if isinstance(last_workspace, dict) else None)
205
+ or (last_workspace.get("workspaceName") if isinstance(last_workspace, dict) else None)
206
+ or (last_workspace.get("remark") if isinstance(last_workspace, dict) else None)
207
+ )
208
+ workspace_qf_version = (
209
+ self._workspace_system_version(last_workspace) if isinstance(last_workspace, dict) else None
210
+ )
211
+ if selected_ws_id is None:
212
+ fallback_workspace, fallback_qf_version = self._fetch_first_workspace(
213
+ normalized_base_url,
214
+ normalized_token,
215
+ qf_version=normalized_qf_version,
216
+ qf_version_source=qf_version_source,
217
+ )
218
+ if isinstance(fallback_workspace, dict):
219
+ selected_ws_id = self._coerce_positive_int(fallback_workspace.get("wsId"))
220
+ selected_ws_name = self._normalize_text(
221
+ fallback_workspace.get("workspaceName")
222
+ or fallback_workspace.get("wsName")
223
+ or fallback_workspace.get("remark")
224
+ ) or selected_ws_name
225
+ workspace_qf_version = self._workspace_system_version(fallback_workspace) or fallback_qf_version
226
+ elif selected_ws_name is None or workspace_qf_version is None:
227
+ workspace = self._fetch_workspace_with_name_fallback(
228
+ normalized_base_url,
229
+ normalized_token,
230
+ selected_ws_id,
231
+ qf_version=normalized_qf_version,
232
+ qf_version_source=qf_version_source,
233
+ )
234
+ if isinstance(workspace, dict):
235
+ selected_ws_name = self._normalize_text(
236
+ workspace.get("workspaceName")
237
+ or workspace.get("wsName")
238
+ or workspace.get("remark")
239
+ ) or selected_ws_name
240
+ workspace_qf_version = self._workspace_system_version(workspace) or workspace_qf_version
241
+
242
+ if workspace_qf_version is not None:
243
+ resolved_qf_version, resolved_qf_version_source = workspace_qf_version, "workspace_system_version"
244
+ else:
245
+ resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
246
+ response_qf_version,
247
+ fallback_qf_version=normalized_qf_version,
248
+ fallback_source=qf_version_source,
249
+ )
250
+
251
+ uid = self._coerce_positive_int(resolved_user_info.get("uid"))
252
+ if uid is None:
253
+ raise_tool_error(QingflowApiError(category="auth", message="Token validation did not return valid user info"))
254
+
255
+ session_profile = self.sessions.save_session(
256
+ profile=profile,
257
+ base_url=normalized_base_url,
258
+ qf_version=resolved_qf_version,
259
+ qf_version_source=resolved_qf_version_source,
260
+ token=normalized_token,
261
+ login_token=normalized_login_token,
262
+ credential=None,
263
+ uid=uid,
264
+ email=self._normalize_text(resolved_user_info.get("email")),
265
+ nick_name=self._normalize_text(
266
+ resolved_user_info.get("nickName")
267
+ or resolved_user_info.get("displayName")
268
+ or resolved_user_info.get("name")
269
+ ),
270
+ persist=persist,
271
+ )
272
+ if selected_ws_id is not None:
273
+ session_profile = self.sessions.select_workspace(profile, ws_id=selected_ws_id, ws_name=selected_ws_name)
274
+ backend_session = self.sessions.get_backend_session(profile)
275
+ permission_level = (
276
+ self._workspace_permission_level(
277
+ session_profile=session_profile,
278
+ backend_session=backend_session,
279
+ )
280
+ if backend_session is not None
281
+ else None
282
+ )
283
+
284
+ return {
285
+ "profile": session_profile.profile,
286
+ "base_url": session_profile.base_url,
287
+ "qf_version": session_profile.qf_version,
288
+ "qf_version_source": session_profile.qf_version_source,
289
+ "uid": session_profile.uid,
290
+ "email": session_profile.email,
291
+ "nick_name": session_profile.nick_name,
292
+ "selected_ws_id": session_profile.selected_ws_id,
293
+ "selected_ws_name": session_profile.selected_ws_name,
294
+ "suggested_ws_id": session_profile.selected_ws_id,
295
+ "suggested_ws_name": session_profile.selected_ws_name,
296
+ "permission_level": permission_level,
297
+ "persisted": session_profile.persisted,
298
+ "request_route": self._request_route_payload(
299
+ BackendRequestContext(
300
+ base_url=session_profile.base_url,
301
+ token=normalized_token,
302
+ ws_id=session_profile.selected_ws_id,
303
+ qf_version=session_profile.qf_version,
304
+ qf_version_source=session_profile.qf_version_source,
305
+ )
306
+ ),
307
+ }
308
+
167
309
  def _resolve_mcporter_auth_inputs(self, *, base_url: str | None, credential: str | None) -> tuple[str | None, str]:
168
310
  """从参数或 mcporter 配置解析登录所需 base_url 与 credential。"""
169
311
  normalized_base_url = self._normalize_text(base_url)
@@ -174,8 +316,12 @@ class AuthTools(ToolBase):
174
316
  mcporter_context = self._read_mcporter_qingflow_context()
175
317
  if not normalized_base_url:
176
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")
177
321
  if not normalized_credential:
178
322
  normalized_credential = self._normalize_text(mcporter_context.get("credential"))
323
+ if not normalized_credential:
324
+ normalized_credential = os.getenv("QINGFLOW_CREDENTIAL")
179
325
  return normalized_base_url, normalized_credential or ""
180
326
 
181
327
  def _read_mcporter_qingflow_context(self) -> dict[str, str]:
@@ -204,6 +350,26 @@ class AuthTools(ToolBase):
204
350
  "credential": str(credential or "").strip(),
205
351
  }
206
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
+
207
373
  @tool_cn_name("我的身份")
208
374
  def auth_whoami(self, *, profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
209
375
  """执行认证与会话相关逻辑。"""
@@ -212,6 +378,36 @@ class AuthTools(ToolBase):
212
378
  backend_session, # type: ignore[no-untyped-def]
213
379
  context: BackendRequestContext,
214
380
  ) -> dict[str, Any]:
381
+ workspace, workspace_qf_version = self._selected_workspace_snapshot(
382
+ session_profile=session_profile,
383
+ backend_session=backend_session,
384
+ )
385
+ resolved_qf_version = workspace_qf_version or session_profile.qf_version
386
+ resolved_qf_version_source = (
387
+ "workspace_system_version"
388
+ if workspace_qf_version is not None
389
+ else session_profile.qf_version_source
390
+ )
391
+ if (
392
+ workspace_qf_version is not None
393
+ and (
394
+ workspace_qf_version != session_profile.qf_version
395
+ or session_profile.qf_version_source != "workspace_system_version"
396
+ )
397
+ ):
398
+ session_profile = self.sessions.update_route(
399
+ profile,
400
+ qf_version=workspace_qf_version,
401
+ qf_version_source="workspace_system_version",
402
+ )
403
+ backend_session = self.sessions.get_backend_session(profile) or backend_session
404
+ context = BackendRequestContext(
405
+ base_url=backend_session.base_url,
406
+ token=backend_session.token,
407
+ ws_id=session_profile.selected_ws_id,
408
+ qf_version=backend_session.qf_version,
409
+ qf_version_source=backend_session.qf_version_source,
410
+ )
215
411
  if self._should_refresh_identity_metadata(session_profile):
216
412
  refreshed_profile = self._refresh_identity_metadata(
217
413
  profile=profile,
@@ -224,8 +420,8 @@ class AuthTools(ToolBase):
224
420
  response = {
225
421
  "profile": session_profile.profile,
226
422
  "base_url": session_profile.base_url,
227
- "qf_version": session_profile.qf_version,
228
- "qf_version_source": session_profile.qf_version_source,
423
+ "qf_version": resolved_qf_version,
424
+ "qf_version_source": resolved_qf_version_source,
229
425
  "uid": session_profile.uid,
230
426
  "email": session_profile.email,
231
427
  "nick_name": session_profile.nick_name,
@@ -512,6 +708,24 @@ class AuthTools(ToolBase):
512
708
  raise_tool_error(QingflowApiError(category="workspace", message=f"Workspace {ws_id} is not accessible"))
513
709
  return workspace
514
710
 
711
+ def _selected_workspace_snapshot(
712
+ self,
713
+ *,
714
+ session_profile, # type: ignore[no-untyped-def]
715
+ backend_session, # type: ignore[no-untyped-def]
716
+ ) -> tuple[dict[str, Any] | None, str | None]:
717
+ ws_id = session_profile.selected_ws_id
718
+ if ws_id is None:
719
+ return None, None
720
+ workspace = self._fetch_workspace_with_name_fallback(
721
+ session_profile.base_url,
722
+ backend_session.token,
723
+ ws_id,
724
+ qf_version=session_profile.qf_version,
725
+ qf_version_source=session_profile.qf_version_source,
726
+ )
727
+ return workspace, self._workspace_system_version(workspace)
728
+
515
729
  def _request_route_payload(self, context: BackendRequestContext) -> dict[str, Any]:
516
730
  """执行内部辅助逻辑。"""
517
731
  describe_route = getattr(self.backend, "describe_route", None)
@@ -603,6 +817,13 @@ class AuthTools(ToolBase):
603
817
  if ws_id is None:
604
818
  return default_payload, []
605
819
 
820
+ permission_level = self._workspace_permission_level(
821
+ session_profile=session_profile,
822
+ backend_session=backend_session,
823
+ )
824
+ payload = dict(default_payload)
825
+ payload["permission_level"] = permission_level
826
+
606
827
  context = BackendRequestContext(
607
828
  base_url=backend_session.base_url,
608
829
  token=backend_session.token,
@@ -610,12 +831,6 @@ class AuthTools(ToolBase):
610
831
  qf_version=backend_session.qf_version,
611
832
  qf_version_source=backend_session.qf_version_source,
612
833
  )
613
- permission_level = self._resolve_permission_level(
614
- self._workspace_auth(context, ws_id=ws_id)
615
- )
616
- payload = dict(default_payload)
617
- payload["permission_level"] = permission_level
618
-
619
834
  member = self._lookup_current_member(
620
835
  context=context,
621
836
  uid=session_profile.uid,
@@ -637,6 +852,25 @@ class AuthTools(ToolBase):
637
852
  payload["roles"] = self._compact_roles(member)
638
853
  return payload, []
639
854
 
855
+ def _workspace_permission_level(
856
+ self,
857
+ *,
858
+ session_profile, # type: ignore[no-untyped-def]
859
+ backend_session, # type: ignore[no-untyped-def]
860
+ ) -> str | None:
861
+ """Resolve the selected workspace permission label without requiring member lookup."""
862
+ ws_id = session_profile.selected_ws_id
863
+ if ws_id is None:
864
+ return None
865
+ context = BackendRequestContext(
866
+ base_url=backend_session.base_url,
867
+ token=backend_session.token,
868
+ ws_id=ws_id,
869
+ qf_version=backend_session.qf_version,
870
+ qf_version_source=backend_session.qf_version_source,
871
+ )
872
+ return self._resolve_permission_level(self._workspace_auth(context, ws_id=ws_id))
873
+
640
874
  def _workspace_auth(self, context: BackendRequestContext, *, ws_id: int) -> int | None:
641
875
  """执行内部辅助逻辑。"""
642
876
  workspace = self._fetch_workspace_auth_from_detail(context, ws_id=ws_id)
@@ -47,7 +47,11 @@ class ToolBase:
47
47
  context = BackendRequestContext(
48
48
  base_url=backend_session.base_url,
49
49
  token=backend_session.token,
50
- ws_id=session_profile.selected_ws_id if require_workspace else None,
50
+ # Keep the selected workspace in context whenever we know it.
51
+ # Some top-level tools (for example workspace browsing) do not
52
+ # require a workspace to execute, but credit metering still needs
53
+ # a stable wsId when the profile already has one selected.
54
+ ws_id=session_profile.selected_ws_id,
51
55
  qf_version=backend_session.qf_version,
52
56
  qf_version_source=backend_session.qf_version_source,
53
57
  )
@@ -96,7 +100,7 @@ class ToolBase:
96
100
  self.sessions.invalidate(profile)
97
101
  error = QingflowApiError(
98
102
  category="auth",
99
- message=f"Qingflow session for profile '{profile}' has expired. Run auth_use_credential again.",
103
+ message=f"Qingflow session for profile '{profile}' has expired. Run auth login or auth_use_credential again.",
100
104
  backend_code=error.backend_code,
101
105
  request_id=error.request_id,
102
106
  http_status=error.http_status,