@qingflow-tech/qingflow-app-user-mcp 1.0.1 → 1.0.3

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 (45) 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/builder_facade/models.py +14 -4
  16. package/src/qingflow_mcp/builder_facade/service.py +1582 -124
  17. package/src/qingflow_mcp/cli/commands/auth.py +69 -1
  18. package/src/qingflow_mcp/cli/commands/builder.py +4 -3
  19. package/src/qingflow_mcp/cli/commands/record.py +5 -5
  20. package/src/qingflow_mcp/cli/commands/task.py +74 -22
  21. package/src/qingflow_mcp/cli/commands/workspace.py +22 -0
  22. package/src/qingflow_mcp/cli/formatters.py +287 -48
  23. package/src/qingflow_mcp/cli/main.py +6 -1
  24. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  25. package/src/qingflow_mcp/config.py +8 -0
  26. package/src/qingflow_mcp/errors.py +2 -2
  27. package/src/qingflow_mcp/id_utils.py +49 -0
  28. package/src/qingflow_mcp/public_surface.py +11 -1
  29. package/src/qingflow_mcp/response_trim.py +380 -9
  30. package/src/qingflow_mcp/server.py +4 -0
  31. package/src/qingflow_mcp/server_app_builder.py +11 -1
  32. package/src/qingflow_mcp/server_app_user.py +24 -0
  33. package/src/qingflow_mcp/session_store.py +69 -15
  34. package/src/qingflow_mcp/solution/compiler/form_compiler.py +2 -2
  35. package/src/qingflow_mcp/solution/executor.py +2 -2
  36. package/src/qingflow_mcp/tools/ai_builder_tools.py +48 -18
  37. package/src/qingflow_mcp/tools/app_tools.py +1 -0
  38. package/src/qingflow_mcp/tools/auth_tools.py +271 -12
  39. package/src/qingflow_mcp/tools/base.py +6 -2
  40. package/src/qingflow_mcp/tools/code_block_tools.py +2 -2
  41. package/src/qingflow_mcp/tools/import_tools.py +36 -2
  42. package/src/qingflow_mcp/tools/record_tools.py +410 -156
  43. package/src/qingflow_mcp/tools/resource_read_tools.py +114 -32
  44. package/src/qingflow_mcp/tools/task_context_tools.py +899 -141
  45. package/src/qingflow_mcp/tools/workspace_tools.py +141 -0
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import os
4
5
  from dataclasses import asdict, dataclass
5
6
  from datetime import datetime, timezone
6
7
  from pathlib import Path
@@ -77,6 +78,7 @@ class SessionStore:
77
78
  profiles_path = get_profiles_path() if base_dir is None else Path(base_dir) / "profiles.json"
78
79
  self._profiles_path = profiles_path
79
80
  self._profiles_path.parent.mkdir(parents=True, exist_ok=True)
81
+ self._secrets_path = self._profiles_path.parent / "secrets.json"
80
82
  self._keyring = keyring_backend if keyring_backend is not None else keyring
81
83
  self._memory_sessions: dict[str, BackendSession] = {}
82
84
  self._logged_out_profiles: set[str] = set()
@@ -290,26 +292,78 @@ class SessionStore:
290
292
  json.dump(payload, handle, ensure_ascii=False, indent=2)
291
293
 
292
294
  def _set_secret(self, key: str, value: str) -> bool:
293
- if self._keyring is None:
294
- return False
295
+ if self._keyring is not None:
296
+ try:
297
+ self._keyring.set_password(KEYRING_SERVICE_NAME, key, value)
298
+ self._delete_file_secret(key)
299
+ return True
300
+ except Exception:
301
+ pass
302
+ return self._set_file_secret(key, value)
303
+
304
+ def _get_secret(self, key: str) -> str | None:
305
+ if self._keyring is not None:
306
+ try:
307
+ value = self._keyring.get_password(KEYRING_SERVICE_NAME, key)
308
+ except Exception:
309
+ value = None
310
+ if value:
311
+ return value
312
+ return self._get_file_secret(key)
313
+
314
+ def _delete_secret(self, key: str) -> None:
315
+ if self._keyring is not None:
316
+ try:
317
+ self._keyring.delete_password(KEYRING_SERVICE_NAME, key)
318
+ except Exception:
319
+ pass
320
+ self._delete_file_secret(key)
321
+
322
+ def _load_file_secrets(self) -> dict[str, str]:
323
+ if not self._secrets_path.exists():
324
+ return {}
325
+ try:
326
+ with self._secrets_path.open("r", encoding="utf-8") as handle:
327
+ payload = json.load(handle)
328
+ except (OSError, json.JSONDecodeError):
329
+ return {}
330
+ if not isinstance(payload, dict):
331
+ return {}
332
+ return {str(key): str(value) for key, value in payload.items() if isinstance(value, str)}
333
+
334
+ def _save_file_secrets(self, payload: dict[str, str]) -> bool:
335
+ self._secrets_path.parent.mkdir(parents=True, exist_ok=True)
295
336
  try:
296
- self._keyring.set_password(KEYRING_SERVICE_NAME, key, value)
337
+ fd = os.open(self._secrets_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
338
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
339
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
340
+ try:
341
+ os.chmod(self._secrets_path, 0o600)
342
+ except OSError:
343
+ pass
297
344
  return True
298
- except Exception:
345
+ except OSError:
299
346
  return False
300
347
 
301
- def _get_secret(self, key: str) -> str | None:
302
- if self._keyring is None:
303
- return None
304
- try:
305
- return self._keyring.get_password(KEYRING_SERVICE_NAME, key)
306
- except Exception:
307
- return None
348
+ def _set_file_secret(self, key: str, value: str) -> bool:
349
+ payload = self._load_file_secrets()
350
+ payload[key] = value
351
+ return self._save_file_secrets(payload)
308
352
 
309
- def _delete_secret(self, key: str) -> None:
310
- if self._keyring is None:
353
+ def _get_file_secret(self, key: str) -> str | None:
354
+ return self._load_file_secrets().get(key)
355
+
356
+ def _delete_file_secret(self, key: str) -> None:
357
+ payload = self._load_file_secrets()
358
+ if key not in payload:
359
+ return
360
+ payload.pop(key, None)
361
+ if payload:
362
+ self._save_file_secrets(payload)
311
363
  return
312
364
  try:
313
- self._keyring.delete_password(KEYRING_SERVICE_NAME, key)
314
- except Exception:
365
+ self._secrets_path.unlink()
366
+ except FileNotFoundError:
367
+ return
368
+ except OSError:
315
369
  return
@@ -307,7 +307,7 @@ def build_reference_config(field: dict[str, Any], temp_id: int) -> dict[str, Any
307
307
  "queId": que_id,
308
308
  "queTitle": label,
309
309
  "queType": _normalize_reference_que_type(raw_type) or "2",
310
- "queAuth": 1,
310
+ "queAuth": 3,
311
311
  "ordinal": ordinal,
312
312
  "quoteId": temp_id,
313
313
  "_field_id": field_id,
@@ -320,7 +320,7 @@ def build_reference_config(field: dict[str, Any], temp_id: int) -> dict[str, Any
320
320
  auth_ques = []
321
321
  for ordinal, field_id in enumerate(auth_field_ids, start=1):
322
322
  que_id = auth_field_que_ids[ordinal - 1] if ordinal - 1 < len(auth_field_que_ids) else 0
323
- auth_ques.append({"queId": que_id, "queAuth": 1, "_field_id": field_id})
323
+ auth_ques.append({"queId": que_id, "queAuth": 3, "_field_id": field_id})
324
324
  return {
325
325
  "referAppKey": "__TARGET_APP_KEY__",
326
326
  "referQueId": display_field_que_id,
@@ -857,12 +857,12 @@ class SolutionExecutor:
857
857
  if refer_que_id is None:
858
858
  continue
859
859
  resolved["queId"] = refer_que_id
860
- resolved["queAuth"] = int(resolved.get("queAuth", 1))
860
+ resolved["queAuth"] = int(resolved.get("queAuth", 3))
861
861
  auth_ques.append(resolved)
862
862
  if not auth_ques:
863
863
  fallback_que_id = target_meta.get("by_field_id", {}).get(target_field_id)
864
864
  if fallback_que_id is not None:
865
- auth_ques.append({"queId": fallback_que_id, "queAuth": 1})
865
+ auth_ques.append({"queId": fallback_que_id, "queAuth": 3})
866
866
  reference_config["referAuthQues"] = auth_ques
867
867
  reference_config["fieldNameShow"] = bool(reference_config.get("fieldNameShow", True))
868
868
  fill_rules = []
@@ -1873,7 +1873,7 @@ class AiBuilderTools(ToolBase):
1873
1873
  dash_name: str = "",
1874
1874
  package_id: int | None = None,
1875
1875
  publish: bool = True,
1876
- sections: list[JSONObject],
1876
+ sections: list[JSONObject] | None = None,
1877
1877
  visibility: JSONObject | None = None,
1878
1878
  auth: JSONObject | None = None,
1879
1879
  icon: str | None = None,
@@ -2665,6 +2665,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2665
2665
  "field.autoTrigger": "field.auto_trigger",
2666
2666
  "field.customBtnTextStatus": "field.custom_button_text_enabled",
2667
2667
  "field.customBtnText": "field.custom_button_text",
2668
+ "field.subfieldUpdates": "field.subfield_updates",
2668
2669
  },
2669
2670
  "allowed_values": {
2670
2671
  "field.type": [member.value for member in PublicFieldType],
@@ -2730,6 +2731,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2730
2731
  "field.autoTrigger": "field.auto_trigger",
2731
2732
  "field.customBtnTextStatus": "field.custom_button_text_enabled",
2732
2733
  "field.customBtnText": "field.custom_button_text",
2734
+ "field.subfieldUpdates": "field.subfield_updates",
2733
2735
  },
2734
2736
  "allowed_values": {
2735
2737
  "field.type": [member.value for member in PublicFieldType],
@@ -2751,6 +2753,9 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2751
2753
  "relation_mode=multiple maps to referenceConfig.optionalDataNum=0",
2752
2754
  "relation fields now require both display_field and visible_fields in MCP/CLI payloads",
2753
2755
  "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",
2756
+ "update_fields[].set.subfield_updates is the safe patch path for editing existing subtable child fields without rebuilding the entire subtable",
2757
+ "subfield_updates only supports safe child overlays: name, required, description, and nested subfield_updates",
2758
+ "set.subfields remains the full replace/rebuild path for a subtable and is higher risk when hidden relation/reference children exist",
2754
2759
  "department fields accept department_scope with mode=all or mode=custom; custom scope requires explicit departments[].dept_id and optional include_sub_departs",
2755
2760
  "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
2761
  "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 +2803,26 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2798
2803
  "update_fields": [],
2799
2804
  "remove_fields": [],
2800
2805
  },
2806
+ "subfield_update_example": {
2807
+ "profile": "default",
2808
+ "app_key": "APP_REWARD",
2809
+ "publish": True,
2810
+ "add_fields": [],
2811
+ "update_fields": [
2812
+ {
2813
+ "selector": {"que_id": 441305044},
2814
+ "set": {
2815
+ "subfield_updates": [
2816
+ {
2817
+ "selector": {"que_id": 441305045},
2818
+ "set": {"name": "景品名"},
2819
+ }
2820
+ ]
2821
+ },
2822
+ }
2823
+ ],
2824
+ "remove_fields": [],
2825
+ },
2801
2826
  "department_scope_example": {
2802
2827
  "profile": "default",
2803
2828
  "app_key": "APP_LEAD",
@@ -3149,7 +3174,9 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3149
3174
  "execution_notes": [
3150
3175
  "returns builder-side app configuration summary and editability",
3151
3176
  "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",
3177
+ "editability is route-aware builder capability summary, not end-user data visibility",
3178
+ "can_edit_app_base covers app base-info writes such as app_name, icon, and visibility",
3179
+ "can_edit_form covers form/schema routes only and does not imply app base-info writes",
3153
3180
  "returns normalized app visibility when backend auth is readable",
3154
3181
  ],
3155
3182
  "minimal_example": {
@@ -3164,6 +3191,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3164
3191
  "execution_notes": [
3165
3192
  "returns compact current field configuration for one app",
3166
3193
  "use this before app_schema_apply when you need exact field definitions",
3194
+ "subtable fields include nested subfields using the same compact field shape",
3167
3195
  ],
3168
3196
  "minimal_example": {
3169
3197
  "profile": "default",
@@ -3269,14 +3297,15 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3269
3297
  "chart.filter.operator": [member.value for member in ViewFilterOperator],
3270
3298
  **deepcopy(_VISIBILITY_ALLOWED_VALUES),
3271
3299
  },
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
- ],
3300
+ "execution_notes": [
3301
+ "app_charts_apply is immediate-live and does not publish",
3302
+ "chart matching precedence is chart_id first, then exact unique chart name",
3303
+ "when chart names are not unique, supply chart_id instead of guessing by name",
3304
+ "successful create results must return a real backend chart_id",
3305
+ "upsert_charts[].visibility compiles to QingBI base visibleAuth only",
3306
+ "visibility-only updates keep the existing chart config and do not rewrite rawDataConfigDTO.authInfo",
3307
+ *_VISIBILITY_EXECUTION_NOTES,
3308
+ ],
3280
3309
  "minimal_example": {
3281
3310
  "profile": "default",
3282
3311
  "app_key": "APP_KEY",
@@ -3332,14 +3361,15 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3332
3361
  "dashStyleConfigBO": "dash_style_config",
3333
3362
  },
3334
3363
  "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",
3364
+ "execution_notes": [
3365
+ "use exactly one resource mode",
3366
+ "update mode: dash_key",
3367
+ "create mode: package_id + dash_name",
3368
+ "portal_apply uses replace semantics for sections",
3369
+ "when editing an existing portal, sections may be omitted to update only base info such as visibility, icon, or package",
3370
+ "remove a section by omitting it from the new sections list",
3371
+ "package_id is required when creating a new portal",
3372
+ "publish=false only guarantees draft and base-info updates; it does not claim live has changed",
3343
3373
  "chart_ref resolves by chart_id first, then exact unique chart_name",
3344
3374
  "view_ref resolves by view_key first, then exact unique view_name",
3345
3375
  "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,
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  from typing import Any
4
5
 
5
6
  from mcp.server.fastmcp import FastMCP
@@ -9,6 +10,7 @@ from ..config import (
9
10
  DEFAULT_PROFILE,
10
11
  get_default_base_url,
11
12
  get_default_qf_version,
13
+ get_mcporter_config_path,
12
14
  normalize_base_url,
13
15
  )
14
16
  from ..errors import QingflowApiError, raise_tool_error
@@ -77,15 +79,24 @@ class AuthTools(ToolBase):
77
79
  profile: str = DEFAULT_PROFILE,
78
80
  base_url: str | None = None,
79
81
  qf_version: str | None = None,
80
- credential: str,
82
+ credential: str | None = None,
81
83
  persist: bool = False,
82
84
  ) -> dict[str, Any]:
83
85
  """执行认证与会话相关逻辑。"""
84
- normalized_base_url = self._normalize_base_url(base_url)
86
+ resolved_base_url, resolved_credential = self._resolve_mcporter_auth_inputs(
87
+ base_url=base_url,
88
+ credential=credential,
89
+ )
90
+ normalized_base_url = self._normalize_base_url(resolved_base_url)
85
91
  normalized_qf_version, qf_version_source = self._resolve_qf_version_input(qf_version)
86
- normalized_credential = str(credential).strip()
92
+ normalized_credential = str(resolved_credential).strip()
87
93
  if not normalized_credential:
88
- raise_tool_error(QingflowApiError.config_error("credential is required"))
94
+ raise_tool_error(
95
+ QingflowApiError.config_error(
96
+ "credential is required or configure ~/.openclaw/workspace/config/mcporter.json "
97
+ "with mcpServers.qingflow.headers.x-qingflow-client-id"
98
+ )
99
+ )
89
100
 
90
101
  context_payload, detected_qf_version = self._fetch_auth_context(
91
102
  normalized_base_url,
@@ -153,6 +164,186 @@ class AuthTools(ToolBase):
153
164
  ),
154
165
  }
155
166
 
167
+ def auth_use_token(
168
+ self,
169
+ *,
170
+ profile: str = DEFAULT_PROFILE,
171
+ base_url: str | None = None,
172
+ qf_version: str | None = None,
173
+ token: str | None = None,
174
+ login_token: str | None = None,
175
+ persist: bool = False,
176
+ user_info: dict[str, Any] | None = None,
177
+ ) -> dict[str, Any]:
178
+ """使用已获得的 Qingflow token 建立本地会话。"""
179
+ normalized_base_url = self._normalize_base_url(base_url)
180
+ normalized_qf_version, qf_version_source = self._resolve_qf_version_input(qf_version)
181
+ normalized_token = self._normalize_text(token)
182
+ normalized_login_token = self._normalize_text(login_token)
183
+ if not normalized_token:
184
+ raise_tool_error(QingflowApiError.config_error("token is required"))
185
+
186
+ resolved_user_info = user_info if isinstance(user_info, dict) else None
187
+ response_qf_version: str | None = None
188
+ if resolved_user_info is None:
189
+ resolved_user_info, response_qf_version = self._fetch_user_info(
190
+ normalized_base_url,
191
+ normalized_token,
192
+ None,
193
+ qf_version=normalized_qf_version,
194
+ qf_version_source=qf_version_source,
195
+ )
196
+
197
+ last_workspace = resolved_user_info.get("lastWsInfo")
198
+ selected_ws_id = self._coerce_positive_int(
199
+ last_workspace.get("wsId") if isinstance(last_workspace, dict) else None
200
+ )
201
+ selected_ws_name = self._normalize_text(
202
+ (last_workspace.get("wsName") if isinstance(last_workspace, dict) else None)
203
+ or (last_workspace.get("workspaceName") if isinstance(last_workspace, dict) else None)
204
+ or (last_workspace.get("remark") if isinstance(last_workspace, dict) else None)
205
+ )
206
+ workspace_qf_version = (
207
+ self._workspace_system_version(last_workspace) if isinstance(last_workspace, dict) else None
208
+ )
209
+ if selected_ws_id is None:
210
+ fallback_workspace, fallback_qf_version = self._fetch_first_workspace(
211
+ normalized_base_url,
212
+ normalized_token,
213
+ qf_version=normalized_qf_version,
214
+ qf_version_source=qf_version_source,
215
+ )
216
+ if isinstance(fallback_workspace, dict):
217
+ selected_ws_id = self._coerce_positive_int(fallback_workspace.get("wsId"))
218
+ selected_ws_name = self._normalize_text(
219
+ fallback_workspace.get("workspaceName")
220
+ or fallback_workspace.get("wsName")
221
+ or fallback_workspace.get("remark")
222
+ ) or selected_ws_name
223
+ workspace_qf_version = self._workspace_system_version(fallback_workspace) or fallback_qf_version
224
+ elif selected_ws_name is None or workspace_qf_version is None:
225
+ workspace = self._fetch_workspace_with_name_fallback(
226
+ normalized_base_url,
227
+ normalized_token,
228
+ selected_ws_id,
229
+ qf_version=normalized_qf_version,
230
+ qf_version_source=qf_version_source,
231
+ )
232
+ if isinstance(workspace, dict):
233
+ selected_ws_name = self._normalize_text(
234
+ workspace.get("workspaceName")
235
+ or workspace.get("wsName")
236
+ or workspace.get("remark")
237
+ ) or selected_ws_name
238
+ workspace_qf_version = self._workspace_system_version(workspace) or workspace_qf_version
239
+
240
+ if workspace_qf_version is not None:
241
+ resolved_qf_version, resolved_qf_version_source = workspace_qf_version, "workspace_system_version"
242
+ else:
243
+ resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
244
+ response_qf_version,
245
+ fallback_qf_version=normalized_qf_version,
246
+ fallback_source=qf_version_source,
247
+ )
248
+
249
+ uid = self._coerce_positive_int(resolved_user_info.get("uid"))
250
+ if uid is None:
251
+ raise_tool_error(QingflowApiError(category="auth", message="Token validation did not return valid user info"))
252
+
253
+ session_profile = self.sessions.save_session(
254
+ profile=profile,
255
+ base_url=normalized_base_url,
256
+ qf_version=resolved_qf_version,
257
+ qf_version_source=resolved_qf_version_source,
258
+ token=normalized_token,
259
+ login_token=normalized_login_token,
260
+ credential=None,
261
+ uid=uid,
262
+ email=self._normalize_text(resolved_user_info.get("email")),
263
+ nick_name=self._normalize_text(
264
+ resolved_user_info.get("nickName")
265
+ or resolved_user_info.get("displayName")
266
+ or resolved_user_info.get("name")
267
+ ),
268
+ persist=persist,
269
+ )
270
+ if selected_ws_id is not None:
271
+ session_profile = self.sessions.select_workspace(profile, ws_id=selected_ws_id, ws_name=selected_ws_name)
272
+ backend_session = self.sessions.get_backend_session(profile)
273
+ permission_level = (
274
+ self._workspace_permission_level(
275
+ session_profile=session_profile,
276
+ backend_session=backend_session,
277
+ )
278
+ if backend_session is not None
279
+ else None
280
+ )
281
+
282
+ return {
283
+ "profile": session_profile.profile,
284
+ "base_url": session_profile.base_url,
285
+ "qf_version": session_profile.qf_version,
286
+ "qf_version_source": session_profile.qf_version_source,
287
+ "uid": session_profile.uid,
288
+ "email": session_profile.email,
289
+ "nick_name": session_profile.nick_name,
290
+ "selected_ws_id": session_profile.selected_ws_id,
291
+ "selected_ws_name": session_profile.selected_ws_name,
292
+ "suggested_ws_id": session_profile.selected_ws_id,
293
+ "suggested_ws_name": session_profile.selected_ws_name,
294
+ "permission_level": permission_level,
295
+ "persisted": session_profile.persisted,
296
+ "request_route": self._request_route_payload(
297
+ BackendRequestContext(
298
+ base_url=session_profile.base_url,
299
+ token=normalized_token,
300
+ ws_id=session_profile.selected_ws_id,
301
+ qf_version=session_profile.qf_version,
302
+ qf_version_source=session_profile.qf_version_source,
303
+ )
304
+ ),
305
+ }
306
+
307
+ def _resolve_mcporter_auth_inputs(self, *, base_url: str | None, credential: str | None) -> tuple[str | None, str]:
308
+ """从参数或 mcporter 配置解析登录所需 base_url 与 credential。"""
309
+ normalized_base_url = self._normalize_text(base_url)
310
+ normalized_credential = self._normalize_text(credential)
311
+ if normalized_base_url and normalized_credential:
312
+ return normalized_base_url, normalized_credential
313
+
314
+ mcporter_context = self._read_mcporter_qingflow_context()
315
+ if not normalized_base_url:
316
+ normalized_base_url = self._normalize_text(mcporter_context.get("base_url"))
317
+ if not normalized_credential:
318
+ normalized_credential = self._normalize_text(mcporter_context.get("credential"))
319
+ return normalized_base_url, normalized_credential or ""
320
+
321
+ def _read_mcporter_qingflow_context(self) -> dict[str, str]:
322
+ """读取 OpenClaw mcporter 中的 Qingflow MCP 上下文。"""
323
+ path = get_mcporter_config_path()
324
+ if not path.exists():
325
+ return {}
326
+ try:
327
+ with path.open("r", encoding="utf-8") as handle:
328
+ payload = json.load(handle)
329
+ except (OSError, json.JSONDecodeError) as exc:
330
+ raise_tool_error(QingflowApiError.config_error(f"failed to read mcporter config '{path}': {exc}"))
331
+
332
+ if not isinstance(payload, dict):
333
+ raise_tool_error(QingflowApiError.config_error(f"mcporter config '{path}' must be a JSON object"))
334
+ mcp_servers = payload.get("mcpServers")
335
+ qingflow = mcp_servers.get("qingflow") if isinstance(mcp_servers, dict) else None
336
+ if not isinstance(qingflow, dict):
337
+ return {}
338
+ headers = qingflow.get("headers")
339
+ credential = None
340
+ if isinstance(headers, dict):
341
+ credential = headers.get("x-qingflow-client-id")
342
+ return {
343
+ "base_url": str(qingflow.get("url") or "").strip(),
344
+ "credential": str(credential or "").strip(),
345
+ }
346
+
156
347
  @tool_cn_name("我的身份")
157
348
  def auth_whoami(self, *, profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
158
349
  """执行认证与会话相关逻辑。"""
@@ -161,6 +352,36 @@ class AuthTools(ToolBase):
161
352
  backend_session, # type: ignore[no-untyped-def]
162
353
  context: BackendRequestContext,
163
354
  ) -> dict[str, Any]:
355
+ workspace, workspace_qf_version = self._selected_workspace_snapshot(
356
+ session_profile=session_profile,
357
+ backend_session=backend_session,
358
+ )
359
+ resolved_qf_version = workspace_qf_version or session_profile.qf_version
360
+ resolved_qf_version_source = (
361
+ "workspace_system_version"
362
+ if workspace_qf_version is not None
363
+ else session_profile.qf_version_source
364
+ )
365
+ if (
366
+ workspace_qf_version is not None
367
+ and (
368
+ workspace_qf_version != session_profile.qf_version
369
+ or session_profile.qf_version_source != "workspace_system_version"
370
+ )
371
+ ):
372
+ session_profile = self.sessions.update_route(
373
+ profile,
374
+ qf_version=workspace_qf_version,
375
+ qf_version_source="workspace_system_version",
376
+ )
377
+ backend_session = self.sessions.get_backend_session(profile) or backend_session
378
+ context = BackendRequestContext(
379
+ base_url=backend_session.base_url,
380
+ token=backend_session.token,
381
+ ws_id=session_profile.selected_ws_id,
382
+ qf_version=backend_session.qf_version,
383
+ qf_version_source=backend_session.qf_version_source,
384
+ )
164
385
  if self._should_refresh_identity_metadata(session_profile):
165
386
  refreshed_profile = self._refresh_identity_metadata(
166
387
  profile=profile,
@@ -173,8 +394,8 @@ class AuthTools(ToolBase):
173
394
  response = {
174
395
  "profile": session_profile.profile,
175
396
  "base_url": session_profile.base_url,
176
- "qf_version": session_profile.qf_version,
177
- "qf_version_source": session_profile.qf_version_source,
397
+ "qf_version": resolved_qf_version,
398
+ "qf_version_source": resolved_qf_version_source,
178
399
  "uid": session_profile.uid,
179
400
  "email": session_profile.email,
180
401
  "nick_name": session_profile.nick_name,
@@ -461,6 +682,24 @@ class AuthTools(ToolBase):
461
682
  raise_tool_error(QingflowApiError(category="workspace", message=f"Workspace {ws_id} is not accessible"))
462
683
  return workspace
463
684
 
685
+ def _selected_workspace_snapshot(
686
+ self,
687
+ *,
688
+ session_profile, # type: ignore[no-untyped-def]
689
+ backend_session, # type: ignore[no-untyped-def]
690
+ ) -> tuple[dict[str, Any] | None, str | None]:
691
+ ws_id = session_profile.selected_ws_id
692
+ if ws_id is None:
693
+ return None, None
694
+ workspace = self._fetch_workspace_with_name_fallback(
695
+ session_profile.base_url,
696
+ backend_session.token,
697
+ ws_id,
698
+ qf_version=session_profile.qf_version,
699
+ qf_version_source=session_profile.qf_version_source,
700
+ )
701
+ return workspace, self._workspace_system_version(workspace)
702
+
464
703
  def _request_route_payload(self, context: BackendRequestContext) -> dict[str, Any]:
465
704
  """执行内部辅助逻辑。"""
466
705
  describe_route = getattr(self.backend, "describe_route", None)
@@ -552,6 +791,13 @@ class AuthTools(ToolBase):
552
791
  if ws_id is None:
553
792
  return default_payload, []
554
793
 
794
+ permission_level = self._workspace_permission_level(
795
+ session_profile=session_profile,
796
+ backend_session=backend_session,
797
+ )
798
+ payload = dict(default_payload)
799
+ payload["permission_level"] = permission_level
800
+
555
801
  context = BackendRequestContext(
556
802
  base_url=backend_session.base_url,
557
803
  token=backend_session.token,
@@ -559,12 +805,6 @@ class AuthTools(ToolBase):
559
805
  qf_version=backend_session.qf_version,
560
806
  qf_version_source=backend_session.qf_version_source,
561
807
  )
562
- permission_level = self._resolve_permission_level(
563
- self._workspace_auth(context, ws_id=ws_id)
564
- )
565
- payload = dict(default_payload)
566
- payload["permission_level"] = permission_level
567
-
568
808
  member = self._lookup_current_member(
569
809
  context=context,
570
810
  uid=session_profile.uid,
@@ -586,6 +826,25 @@ class AuthTools(ToolBase):
586
826
  payload["roles"] = self._compact_roles(member)
587
827
  return payload, []
588
828
 
829
+ def _workspace_permission_level(
830
+ self,
831
+ *,
832
+ session_profile, # type: ignore[no-untyped-def]
833
+ backend_session, # type: ignore[no-untyped-def]
834
+ ) -> str | None:
835
+ """Resolve the selected workspace permission label without requiring member lookup."""
836
+ ws_id = session_profile.selected_ws_id
837
+ if ws_id is None:
838
+ return None
839
+ context = BackendRequestContext(
840
+ base_url=backend_session.base_url,
841
+ token=backend_session.token,
842
+ ws_id=ws_id,
843
+ qf_version=backend_session.qf_version,
844
+ qf_version_source=backend_session.qf_version_source,
845
+ )
846
+ return self._resolve_permission_level(self._workspace_auth(context, ws_id=ws_id))
847
+
589
848
  def _workspace_auth(self, context: BackendRequestContext, *, ws_id: int) -> int | None:
590
849
  """执行内部辅助逻辑。"""
591
850
  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,