@josephyan/qingflow-cli 0.2.0-beta.985 → 0.2.0-beta.987

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 (44) hide show
  1. package/README.md +2 -2
  2. package/docs/local-agent-install.md +70 -11
  3. package/package.json +1 -1
  4. package/pyproject.toml +1 -1
  5. package/src/qingflow_mcp/__init__.py +1 -1
  6. package/src/qingflow_mcp/builder_facade/service.py +376 -19
  7. package/src/qingflow_mcp/cli/commands/auth.py +14 -43
  8. package/src/qingflow_mcp/cli/commands/workspace.py +8 -5
  9. package/src/qingflow_mcp/cli/formatters.py +19 -22
  10. package/src/qingflow_mcp/config.py +39 -0
  11. package/src/qingflow_mcp/errors.py +2 -2
  12. package/src/qingflow_mcp/public_surface.py +4 -6
  13. package/src/qingflow_mcp/response_trim.py +1 -8
  14. package/src/qingflow_mcp/server.py +1 -1
  15. package/src/qingflow_mcp/server_app_builder.py +4 -28
  16. package/src/qingflow_mcp/server_app_user.py +4 -28
  17. package/src/qingflow_mcp/session_store.py +31 -5
  18. package/src/qingflow_mcp/solution/compiler/form_compiler.py +2 -2
  19. package/src/qingflow_mcp/solution/executor.py +2 -2
  20. package/src/qingflow_mcp/tools/ai_builder_tools.py +117 -1
  21. package/src/qingflow_mcp/tools/app_tools.py +51 -1
  22. package/src/qingflow_mcp/tools/approval_tools.py +82 -1
  23. package/src/qingflow_mcp/tools/auth_tools.py +306 -288
  24. package/src/qingflow_mcp/tools/base.py +204 -4
  25. package/src/qingflow_mcp/tools/code_block_tools.py +21 -0
  26. package/src/qingflow_mcp/tools/custom_button_tools.py +24 -1
  27. package/src/qingflow_mcp/tools/directory_tools.py +28 -1
  28. package/src/qingflow_mcp/tools/feedback_tools.py +8 -0
  29. package/src/qingflow_mcp/tools/file_tools.py +25 -1
  30. package/src/qingflow_mcp/tools/import_tools.py +40 -1
  31. package/src/qingflow_mcp/tools/navigation_tools.py +34 -1
  32. package/src/qingflow_mcp/tools/package_tools.py +37 -1
  33. package/src/qingflow_mcp/tools/portal_tools.py +28 -1
  34. package/src/qingflow_mcp/tools/qingbi_report_tools.py +38 -1
  35. package/src/qingflow_mcp/tools/record_tools.py +255 -2
  36. package/src/qingflow_mcp/tools/repository_dev_tools.py +21 -2
  37. package/src/qingflow_mcp/tools/resource_read_tools.py +23 -1
  38. package/src/qingflow_mcp/tools/role_tools.py +19 -1
  39. package/src/qingflow_mcp/tools/solution_tools.py +56 -1
  40. package/src/qingflow_mcp/tools/task_context_tools.py +72 -1
  41. package/src/qingflow_mcp/tools/task_tools.py +49 -3
  42. package/src/qingflow_mcp/tools/view_tools.py +56 -1
  43. package/src/qingflow_mcp/tools/workflow_tools.py +65 -1
  44. package/src/qingflow_mcp/tools/workspace_tools.py +100 -217
@@ -1,154 +1,145 @@
1
1
  from __future__ import annotations
2
2
 
3
- import base64
3
+ import json
4
4
  from typing import Any
5
5
 
6
6
  from mcp.server.fastmcp import FastMCP
7
- from Crypto.PublicKey import RSA
8
- from Crypto.Cipher import PKCS1_v1_5
9
7
 
10
8
  from ..backend_client import BackendRequestContext, BackendResponse
11
9
  from ..config import (
12
10
  DEFAULT_PROFILE,
13
11
  get_default_base_url,
14
12
  get_default_qf_version,
13
+ get_mcporter_config_path,
15
14
  normalize_base_url,
16
15
  )
17
16
  from ..errors import QingflowApiError, raise_tool_error
18
17
  from ..session_store import SessionStore
19
- from .base import ToolBase
18
+ from .base import ToolBase, tool_cn_name
20
19
 
21
20
 
22
21
  class AuthTools(ToolBase):
22
+ """认证类工具(中文名:身份与会话工具)。
23
+
24
+ 主要职责:
25
+ 1. 使用 credential 调用 /mcp/auth/context 建立会话;
26
+ 2. 查询当前登录身份与工作区成员信息;
27
+ 3. 退出并清理当前 profile 会话。
28
+ """
29
+
23
30
  def __init__(self, sessions: SessionStore, backend) -> None:
31
+ """执行内部辅助逻辑。"""
24
32
  super().__init__(sessions, backend)
25
33
 
26
34
  def register(self, mcp: FastMCP) -> None:
27
- @mcp.tool()
28
- def auth_login(
29
- profile: str = DEFAULT_PROFILE,
30
- base_url: str | None = None,
31
- qf_version: str | None = None,
32
- email: str = "",
33
- password: str = "",
34
- persist: bool = True,
35
- ) -> dict[str, Any]:
36
- return self.auth_login(profile=profile, base_url=base_url, qf_version=qf_version, email=email, password=password, persist=persist)
37
-
38
- @mcp.tool()
39
- def auth_use_token(
35
+ """注册当前工具到 MCP 服务。"""
36
+ @mcp.tool(
37
+ description=(
38
+ "类型:认证工具;中文名:凭证登录。"
39
+ "用途:使用 createClaw 提供的 credential 交换上下文并建立本地会话。"
40
+ )
41
+ )
42
+ def auth_use_credential(
40
43
  profile: str = DEFAULT_PROFILE,
41
44
  base_url: str | None = None,
42
45
  qf_version: str | None = None,
43
- token: str = "",
44
- ws_id: int | None = None,
46
+ credential: str = "",
45
47
  persist: bool = False,
46
48
  ) -> dict[str, Any]:
47
- return self.auth_use_token(profile=profile, base_url=base_url, qf_version=qf_version, token=token, ws_id=ws_id, persist=persist)
49
+ return self.auth_use_credential(
50
+ profile=profile,
51
+ base_url=base_url,
52
+ qf_version=qf_version,
53
+ credential=credential,
54
+ persist=persist,
55
+ )
48
56
 
49
- @mcp.tool()
57
+ @mcp.tool(
58
+ description=(
59
+ "类型:认证工具;中文名:我的身份。"
60
+ "用途:查看当前 profile 的登录身份、工作区与权限信息。"
61
+ )
62
+ )
50
63
  def auth_whoami(profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
51
64
  return self.auth_whoami(profile=profile)
52
65
 
53
- @mcp.tool()
66
+ @mcp.tool(
67
+ description=(
68
+ "类型:认证工具;中文名:退出登录。"
69
+ "用途:退出当前 profile,并可选清理持久化会话。"
70
+ )
71
+ )
54
72
  def auth_logout(profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict[str, Any]:
55
73
  return self.auth_logout(profile=profile, forget_persisted=forget_persisted)
56
74
 
57
- def auth_login(
75
+ @tool_cn_name("凭证登录")
76
+ def auth_use_credential(
58
77
  self,
59
78
  *,
60
79
  profile: str = DEFAULT_PROFILE,
61
80
  base_url: str | None = None,
62
81
  qf_version: str | None = None,
63
- email: str,
64
- password: str,
65
- persist: bool,
82
+ credential: str | None = None,
83
+ persist: bool = False,
66
84
  ) -> dict[str, Any]:
67
- normalized_base_url = self._normalize_base_url(base_url)
85
+ """执行认证与会话相关逻辑。"""
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)
68
91
  normalized_qf_version, qf_version_source = self._resolve_qf_version_input(qf_version)
69
- if not email or not password:
70
- raise_tool_error(QingflowApiError.config_error("email and password are required"))
71
-
72
- # Try to fetch public key and encrypt password
73
- public_key_str = self._fetch_public_key(normalized_base_url, qf_version=normalized_qf_version)
74
- encrypted_password = self._encrypt_password(password, public_key_str) if public_key_str else password
75
-
76
- try:
77
- # Try 'email' first (aPaas/Public Cloud style)
78
- login_response = self.backend.public_request_with_meta(
79
- "POST",
80
- normalized_base_url,
81
- "/user/login",
82
- json_body={"email": email, "password": encrypted_password},
83
- qf_version=normalized_qf_version,
84
- )
85
- except QingflowApiError as error:
86
- # If failed, try 'account' (QMC/Private Cloud style)
87
- try:
88
- login_response = self.backend.public_request_with_meta(
89
- "POST",
90
- normalized_base_url,
91
- "/user/login",
92
- json_body={"account": email, "password": encrypted_password},
93
- qf_version=normalized_qf_version,
94
- )
95
- except QingflowApiError:
96
- # If both failed, raise the original error
97
- self._handle_error(profile, error)
98
- raise AssertionError("unreachable")
99
- login_result = login_response.data
100
- if not isinstance(login_result, dict):
101
- raise_tool_error(QingflowApiError(category="auth", message="Login did not return a valid result"))
102
-
103
- token = login_result.get("token")
104
- login_token = login_result.get("loginToken")
105
-
106
- if not token and login_token:
92
+ normalized_credential = str(resolved_credential).strip()
93
+ if not normalized_credential:
107
94
  raise_tool_error(
108
- QingflowApiError.not_supported(
109
- "Current environment requires an additional login challenge. Qingflow MCP v1 only supports direct token login."
95
+ QingflowApiError.config_error(
96
+ "credential is required or configure ~/.openclaw/workspace/config/mcporter.json "
97
+ "with mcpServers.qingflow.headers.x-qingflow-client-id"
110
98
  )
111
99
  )
112
-
100
+
101
+ context_payload, detected_qf_version = self._fetch_auth_context(
102
+ normalized_base_url,
103
+ normalized_credential,
104
+ qf_version=normalized_qf_version,
105
+ )
106
+ token = self._normalize_text(context_payload.get("token"))
113
107
  if not token:
114
- raise_tool_error(QingflowApiError(category="auth", message="Login did not return a valid Qingflow token"))
115
- detected_qf_version = login_response.qf_response_version
108
+ raise_tool_error(QingflowApiError(category="auth", message="Credential context did not return a valid Qingflow token"))
109
+
110
+ response_qf_version = self._normalize_text(context_payload.get("qfVersion"))
116
111
  resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
117
- detected_qf_version,
112
+ response_qf_version or detected_qf_version,
118
113
  fallback_qf_version=normalized_qf_version,
119
114
  fallback_source=qf_version_source,
120
115
  )
121
-
122
- user_info = login_result.get("userVO") or login_result.get("userInfo") or {}
123
- if not isinstance(user_info, dict):
124
- user_info = {}
125
- verified_user_info, verified_qf_version = self._try_fetch_user_info(
126
- normalized_base_url,
127
- token,
128
- qf_version=resolved_qf_version,
129
- qf_version_source=resolved_qf_version_source,
130
- )
131
- if verified_qf_version:
132
- resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
133
- verified_qf_version,
134
- fallback_qf_version=resolved_qf_version,
135
- fallback_source=resolved_qf_version_source,
136
- )
137
- if isinstance(verified_user_info, dict):
138
- user_info = verified_user_info
139
- last_ws_info = user_info.get("lastWsInfo") or {}
116
+ resolved_base_url = self._normalize_text(context_payload.get("baseUrl")) or normalized_base_url
117
+ selected_ws_id = self._coerce_int(context_payload.get("wsId"))
118
+ if selected_ws_id is None or selected_ws_id <= 0:
119
+ raise_tool_error(QingflowApiError(category="auth", message="Credential context did not return a valid wsId"))
120
+ selected_ws_name = self._normalize_text(context_payload.get("wsName"))
121
+ uid = self._coerce_int(context_payload.get("uid"))
122
+ if uid is None:
123
+ raise_tool_error(QingflowApiError(category="auth", message="Credential context did not return valid user info"))
140
124
  session_profile = self.sessions.save_session(
141
125
  profile=profile,
142
- base_url=normalized_base_url,
126
+ base_url=resolved_base_url,
143
127
  qf_version=resolved_qf_version,
144
128
  qf_version_source=resolved_qf_version_source,
145
129
  token=token,
146
- login_token=login_token,
147
- uid=int(user_info.get("uid")),
148
- email=user_info.get("email"),
149
- nick_name=user_info.get("nickName"),
130
+ login_token=None,
131
+ credential=normalized_credential,
132
+ uid=uid,
133
+ email=self._normalize_text(context_payload.get("email")),
134
+ nick_name=self._normalize_text(
135
+ context_payload.get("nickName")
136
+ or context_payload.get("displayName")
137
+ or context_payload.get("name")
138
+ ),
150
139
  persist=persist,
151
140
  )
141
+ session_profile = self.sessions.select_workspace(profile, ws_id=selected_ws_id, ws_name=selected_ws_name)
142
+
152
143
  return {
153
144
  "profile": session_profile.profile,
154
145
  "base_url": session_profile.base_url,
@@ -159,8 +150,8 @@ class AuthTools(ToolBase):
159
150
  "nick_name": session_profile.nick_name,
160
151
  "selected_ws_id": session_profile.selected_ws_id,
161
152
  "selected_ws_name": session_profile.selected_ws_name,
162
- "suggested_ws_id": last_ws_info.get("wsId"),
163
- "suggested_ws_name": last_ws_info.get("wsName") or last_ws_info.get("workspaceName"),
153
+ "suggested_ws_id": session_profile.selected_ws_id,
154
+ "suggested_ws_name": session_profile.selected_ws_name,
164
155
  "persisted": session_profile.persisted,
165
156
  "request_route": self._request_route_payload(
166
157
  BackendRequestContext(
@@ -173,176 +164,170 @@ class AuthTools(ToolBase):
173
164
  ),
174
165
  }
175
166
 
176
- def auth_use_token(
177
- self,
178
- *,
179
- profile: str = DEFAULT_PROFILE,
180
- base_url: str | None = None,
181
- qf_version: str | None = None,
182
- token: str,
183
- ws_id: int | None = None,
184
- persist: bool = False,
185
- ) -> dict[str, Any]:
186
- normalized_base_url = self._normalize_base_url(base_url)
187
- normalized_qf_version, qf_version_source = self._resolve_qf_version_input(qf_version)
188
- qf_version_explicit = qf_version is not None and bool(str(qf_version).strip())
189
- if not token:
190
- raise_tool_error(QingflowApiError.config_error("token is required"))
191
- if ws_id is not None and ws_id <= 0:
192
- raise_tool_error(QingflowApiError.config_error("ws_id must be positive"))
167
+ def _resolve_mcporter_auth_inputs(self, *, base_url: str | None, credential: str | None) -> tuple[str | None, str]:
168
+ """从参数或 mcporter 配置解析登录所需 base_url 与 credential。"""
169
+ normalized_base_url = self._normalize_text(base_url)
170
+ normalized_credential = self._normalize_text(credential)
171
+ if normalized_base_url and normalized_credential:
172
+ return normalized_base_url, normalized_credential
173
+
174
+ mcporter_context = self._read_mcporter_qingflow_context()
175
+ if not normalized_base_url:
176
+ normalized_base_url = self._normalize_text(mcporter_context.get("base_url"))
177
+ if not normalized_credential:
178
+ normalized_credential = self._normalize_text(mcporter_context.get("credential"))
179
+ return normalized_base_url, normalized_credential or ""
180
+
181
+ def _read_mcporter_qingflow_context(self) -> dict[str, str]:
182
+ """读取 OpenClaw mcporter 中的 Qingflow MCP 上下文。"""
183
+ path = get_mcporter_config_path()
184
+ if not path.exists():
185
+ return {}
193
186
  try:
194
- user_info, detected_qf_version = self._fetch_user_info(
195
- normalized_base_url,
196
- token,
197
- ws_id,
198
- qf_version=normalized_qf_version,
199
- qf_version_source=qf_version_source,
200
- )
201
- resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
202
- detected_qf_version,
203
- fallback_qf_version=normalized_qf_version,
204
- fallback_source=qf_version_source,
187
+ with path.open("r", encoding="utf-8") as handle:
188
+ payload = json.load(handle)
189
+ except (OSError, json.JSONDecodeError) as exc:
190
+ raise_tool_error(QingflowApiError.config_error(f"failed to read mcporter config '{path}': {exc}"))
191
+
192
+ if not isinstance(payload, dict):
193
+ raise_tool_error(QingflowApiError.config_error(f"mcporter config '{path}' must be a JSON object"))
194
+ mcp_servers = payload.get("mcpServers")
195
+ qingflow = mcp_servers.get("qingflow") if isinstance(mcp_servers, dict) else None
196
+ if not isinstance(qingflow, dict):
197
+ return {}
198
+ headers = qingflow.get("headers")
199
+ credential = None
200
+ if isinstance(headers, dict):
201
+ credential = headers.get("x-qingflow-client-id")
202
+ return {
203
+ "base_url": str(qingflow.get("url") or "").strip(),
204
+ "credential": str(credential or "").strip(),
205
+ }
206
+
207
+ @tool_cn_name("我的身份")
208
+ def auth_whoami(self, *, profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
209
+ """执行认证与会话相关逻辑。"""
210
+ def build_response(
211
+ session_profile, # type: ignore[no-untyped-def]
212
+ backend_session, # type: ignore[no-untyped-def]
213
+ context: BackendRequestContext,
214
+ ) -> dict[str, Any]:
215
+ workspace, workspace_qf_version = self._selected_workspace_snapshot(
216
+ session_profile=session_profile,
217
+ backend_session=backend_session,
205
218
  )
206
- uid = user_info.get("uid")
207
- if uid is None:
208
- raise_tool_error(QingflowApiError(category="auth", message="Token validation did not return valid user info"))
209
- last_ws_info = user_info.get("lastWsInfo") or {}
210
- session_profile = self.sessions.save_session(
211
- profile=profile,
212
- base_url=normalized_base_url,
213
- qf_version=resolved_qf_version,
214
- qf_version_source=resolved_qf_version_source,
215
- token=token,
216
- login_token=None,
217
- uid=int(uid),
218
- email=user_info.get("email"),
219
- nick_name=user_info.get("nickName") or user_info.get("displayName") or user_info.get("name"),
220
- persist=persist,
219
+ resolved_qf_version = workspace_qf_version or session_profile.qf_version
220
+ resolved_qf_version_source = (
221
+ "workspace_system_version"
222
+ if workspace_qf_version is not None
223
+ else session_profile.qf_version_source
221
224
  )
222
- selected_ws_name = None
223
- if ws_id is not None:
224
- workspace = self._fetch_workspace(
225
- normalized_base_url,
226
- token,
227
- ws_id,
228
- qf_version=resolved_qf_version,
229
- qf_version_source=resolved_qf_version_source,
225
+ if (
226
+ workspace_qf_version is not None
227
+ and (
228
+ workspace_qf_version != session_profile.qf_version
229
+ or session_profile.qf_version_source != "workspace_system_version"
230
230
  )
231
- workspace_qf_version = self._workspace_system_version(workspace)
232
- if not qf_version_explicit and workspace_qf_version is not None and workspace_qf_version != resolved_qf_version:
233
- resolved_qf_version = workspace_qf_version
234
- resolved_qf_version_source = "workspace_system_version"
235
- session_profile = self.sessions.update_route(
236
- profile,
237
- qf_version=resolved_qf_version,
238
- qf_version_source=resolved_qf_version_source,
239
- )
240
- selected_ws_name = workspace.get("workspaceName") or workspace.get("wsName") or workspace.get("remark")
241
- session_profile = self.sessions.select_workspace(profile, ws_id=ws_id, ws_name=selected_ws_name)
242
- return {
231
+ ):
232
+ session_profile = self.sessions.update_route(
233
+ profile,
234
+ qf_version=workspace_qf_version,
235
+ qf_version_source="workspace_system_version",
236
+ )
237
+ backend_session = self.sessions.get_backend_session(profile) or backend_session
238
+ context = BackendRequestContext(
239
+ base_url=backend_session.base_url,
240
+ token=backend_session.token,
241
+ ws_id=session_profile.selected_ws_id,
242
+ qf_version=backend_session.qf_version,
243
+ qf_version_source=backend_session.qf_version_source,
244
+ )
245
+ if self._should_refresh_identity_metadata(session_profile):
246
+ refreshed_profile = self._refresh_identity_metadata(
247
+ profile=profile,
248
+ session_profile=session_profile,
249
+ backend_session=backend_session,
250
+ context=context,
251
+ )
252
+ if refreshed_profile is not None:
253
+ session_profile = refreshed_profile
254
+ response = {
243
255
  "profile": session_profile.profile,
244
256
  "base_url": session_profile.base_url,
245
- "qf_version": session_profile.qf_version,
246
- "qf_version_source": session_profile.qf_version_source,
257
+ "qf_version": resolved_qf_version,
258
+ "qf_version_source": resolved_qf_version_source,
247
259
  "uid": session_profile.uid,
248
260
  "email": session_profile.email,
249
261
  "nick_name": session_profile.nick_name,
250
262
  "selected_ws_id": session_profile.selected_ws_id,
251
263
  "selected_ws_name": session_profile.selected_ws_name,
252
- "suggested_ws_id": last_ws_info.get("wsId"),
253
- "suggested_ws_name": last_ws_info.get("wsName") or last_ws_info.get("workspaceName"),
254
264
  "persisted": session_profile.persisted,
255
- "request_route": self._request_route_payload(
256
- BackendRequestContext(
257
- base_url=session_profile.base_url,
258
- token=token,
259
- ws_id=session_profile.selected_ws_id,
260
- qf_version=session_profile.qf_version,
261
- qf_version_source=session_profile.qf_version_source,
262
- )
263
- ),
265
+ "request_route": self._request_route_payload(context),
264
266
  }
265
- except QingflowApiError as error:
266
- if self._should_allow_provisional_token_session(error):
267
- session_profile = self.sessions.save_session(
268
- profile=profile,
269
- base_url=normalized_base_url,
270
- qf_version=normalized_qf_version,
271
- qf_version_source=qf_version_source,
272
- token=token,
273
- login_token=None,
274
- uid=0,
275
- email=None,
276
- nick_name=None,
277
- persist=persist,
278
- )
279
- if ws_id is not None:
280
- session_profile = self.sessions.select_workspace(profile, ws_id=ws_id, ws_name=None)
281
- return {
282
- "profile": session_profile.profile,
283
- "base_url": session_profile.base_url,
284
- "qf_version": session_profile.qf_version,
285
- "qf_version_source": session_profile.qf_version_source,
286
- "uid": session_profile.uid,
287
- "email": session_profile.email,
288
- "nick_name": session_profile.nick_name,
289
- "selected_ws_id": session_profile.selected_ws_id,
290
- "selected_ws_name": session_profile.selected_ws_name,
291
- "suggested_ws_id": ws_id,
292
- "suggested_ws_name": None,
293
- "persisted": session_profile.persisted,
294
- "provisional": True,
295
- "request_route": self._request_route_payload(
296
- BackendRequestContext(
297
- base_url=session_profile.base_url,
298
- token=token,
299
- ws_id=session_profile.selected_ws_id,
300
- qf_version=session_profile.qf_version,
301
- qf_version_source=session_profile.qf_version_source,
302
- )
303
- ),
304
- }
305
- self._handle_error(profile, error)
306
- raise AssertionError("unreachable")
267
+ member_info, member_warnings = self._workspace_member_info(
268
+ session_profile=session_profile,
269
+ backend_session=backend_session,
270
+ )
271
+ response.update(member_info)
272
+ if member_warnings:
273
+ response["warnings"] = member_warnings
274
+ return response
307
275
 
308
- def auth_whoami(self, *, profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
276
+ session_profile = None
277
+ backend_session = None
309
278
  try:
310
279
  session_profile, backend_session, context = self._require_context(profile, require_workspace=False)
280
+ if backend_session.credential:
281
+ self._probe_token_validity(
282
+ session_profile=session_profile,
283
+ backend_session=backend_session,
284
+ )
285
+ return build_response(session_profile, backend_session, context)
311
286
  except QingflowApiError as error:
287
+ if (
288
+ error.looks_like_invalid_token()
289
+ and session_profile is not None
290
+ and backend_session is not None
291
+ and self._refresh_session_from_credential(
292
+ profile,
293
+ session_profile=session_profile,
294
+ backend_session=backend_session,
295
+ )
296
+ ):
297
+ try:
298
+ refreshed_profile, refreshed_backend_session, refreshed_context = self._require_context(
299
+ profile,
300
+ require_workspace=False,
301
+ )
302
+ return build_response(refreshed_profile, refreshed_backend_session, refreshed_context)
303
+ except QingflowApiError as refreshed_error:
304
+ self._handle_error(profile, refreshed_error)
312
305
  self._handle_error(profile, error)
313
306
  raise AssertionError("unreachable")
314
- if self._should_refresh_identity_metadata(session_profile):
315
- refreshed_profile = self._refresh_identity_metadata(
316
- profile=profile,
317
- session_profile=session_profile,
318
- backend_session=backend_session,
319
- context=context,
320
- )
321
- if refreshed_profile is not None:
322
- session_profile = refreshed_profile
323
- response = {
324
- "profile": session_profile.profile,
325
- "base_url": session_profile.base_url,
326
- "qf_version": session_profile.qf_version,
327
- "qf_version_source": session_profile.qf_version_source,
328
- "uid": session_profile.uid,
329
- "email": session_profile.email,
330
- "nick_name": session_profile.nick_name,
331
- "selected_ws_id": session_profile.selected_ws_id,
332
- "selected_ws_name": session_profile.selected_ws_name,
333
- "persisted": session_profile.persisted,
334
- "request_route": self._request_route_payload(context),
335
- }
336
- member_info, member_warnings = self._workspace_member_info(
337
- session_profile=session_profile,
338
- backend_session=backend_session,
307
+
308
+ def _probe_token_validity(
309
+ self,
310
+ *,
311
+ session_profile, # type: ignore[no-untyped-def]
312
+ backend_session, # type: ignore[no-untyped-def]
313
+ ) -> None:
314
+ """执行内部辅助逻辑。"""
315
+ probe_context = BackendRequestContext(
316
+ base_url=backend_session.base_url,
317
+ token=backend_session.token,
318
+ ws_id=session_profile.selected_ws_id,
319
+ qf_version=backend_session.qf_version,
320
+ qf_version_source=backend_session.qf_version_source,
339
321
  )
340
- response.update(member_info)
341
- if member_warnings:
342
- response["warnings"] = member_warnings
343
- return response
322
+ try:
323
+ self.backend.request("GET", probe_context, "/user")
324
+ except QingflowApiError as error:
325
+ if error.looks_like_invalid_token():
326
+ raise
344
327
 
328
+ @tool_cn_name("退出登录")
345
329
  def auth_logout(self, *, profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict[str, Any]:
330
+ """执行认证与会话相关逻辑。"""
346
331
  if not self.sessions.has_profile(profile):
347
332
  raise_tool_error(QingflowApiError.auth_required(profile))
348
333
  self.sessions.logout(profile, forget_persisted=forget_persisted)
@@ -353,6 +338,7 @@ class AuthTools(ToolBase):
353
338
  }
354
339
 
355
340
  def _normalize_base_url(self, base_url: str | None) -> str:
341
+ """执行内部辅助逻辑。"""
356
342
  normalized_base_url = normalize_base_url(base_url) or get_default_base_url()
357
343
  if not normalized_base_url:
358
344
  raise_tool_error(
@@ -363,12 +349,14 @@ class AuthTools(ToolBase):
363
349
  return normalized_base_url
364
350
 
365
351
  def _normalize_qf_version(self, qf_version: str | None) -> str | None:
352
+ """执行内部辅助逻辑。"""
366
353
  if qf_version is not None:
367
354
  normalized = str(qf_version).strip()
368
355
  return normalized or None
369
356
  return get_default_qf_version()
370
357
 
371
358
  def _resolve_qf_version_input(self, qf_version: str | None) -> tuple[str | None, str]:
359
+ """执行内部辅助逻辑。"""
372
360
  if qf_version is not None:
373
361
  normalized = self._normalize_qf_version(qf_version)
374
362
  return normalized, "explicit" if normalized else "unset"
@@ -377,11 +365,6 @@ class AuthTools(ToolBase):
377
365
  return normalized, "default_config"
378
366
  return None, "unset"
379
367
 
380
- def _should_allow_provisional_token_session(self, error: QingflowApiError) -> bool:
381
- if error.looks_like_invalid_token():
382
- return False
383
- return error.http_status == 404 and error.category in {"http", "auth", "workspace"}
384
-
385
368
  def _resolve_backend_qf_version(
386
369
  self,
387
370
  backend_qf_version: str | None,
@@ -389,11 +372,41 @@ class AuthTools(ToolBase):
389
372
  fallback_qf_version: str | None,
390
373
  fallback_source: str,
391
374
  ) -> tuple[str | None, str]:
375
+ """执行内部辅助逻辑。"""
392
376
  if backend_qf_version:
393
377
  return backend_qf_version, "backend_response"
394
378
  return fallback_qf_version, fallback_source
395
379
 
380
+ def _fetch_auth_context(
381
+ self,
382
+ base_url: str,
383
+ credential: str,
384
+ *,
385
+ qf_version: str | None,
386
+ ) -> tuple[dict[str, Any], str | None]:
387
+ """执行内部辅助逻辑。"""
388
+ response = self.backend.public_request_with_meta(
389
+ "POST",
390
+ base_url,
391
+ "/mcp/auth/context",
392
+ json_body={"credential": credential},
393
+ qf_version=qf_version,
394
+ )
395
+ payload = self._unwrap_auth_context_payload(response.data)
396
+ return payload, response.qf_response_version
397
+
398
+ def _unwrap_auth_context_payload(self, payload: Any) -> dict[str, Any]:
399
+ """执行内部辅助逻辑。"""
400
+ if not isinstance(payload, dict):
401
+ raise_tool_error(QingflowApiError(category="auth", message="Credential context did not return a valid result"))
402
+ for key in ("data", "result"):
403
+ nested = payload.get(key)
404
+ if isinstance(nested, dict):
405
+ return nested
406
+ return payload
407
+
396
408
  def _workspace_system_version(self, workspace: Any) -> str | None:
409
+ """执行内部辅助逻辑。"""
397
410
  if not isinstance(workspace, dict):
398
411
  return None
399
412
  value = workspace.get("systemVersion")
@@ -411,6 +424,7 @@ class AuthTools(ToolBase):
411
424
  qf_version: str | None,
412
425
  qf_version_source: str | None,
413
426
  ) -> tuple[dict[str, Any], str | None]:
427
+ """执行内部辅助逻辑。"""
414
428
  request_context = BackendRequestContext(
415
429
  base_url=base_url,
416
430
  token=token,
@@ -461,6 +475,7 @@ class AuthTools(ToolBase):
461
475
  qf_version: str | None,
462
476
  qf_version_source: str | None,
463
477
  ) -> tuple[dict[str, Any] | None, str | None]:
478
+ """执行内部辅助逻辑。"""
464
479
  try:
465
480
  return self._fetch_user_info(
466
481
  base_url,
@@ -480,6 +495,7 @@ class AuthTools(ToolBase):
480
495
  qf_version: str | None,
481
496
  qf_version_source: str | None,
482
497
  ) -> tuple[dict[str, Any] | None, str | None]:
498
+ """执行内部辅助逻辑。"""
483
499
  page_response = self.backend.request_with_meta(
484
500
  "POST",
485
501
  BackendRequestContext(
@@ -510,6 +526,7 @@ class AuthTools(ToolBase):
510
526
  qf_version: str | None,
511
527
  qf_version_source: str | None,
512
528
  ) -> dict[str, Any]:
529
+ """执行内部辅助逻辑。"""
513
530
  workspace = self.backend.request(
514
531
  "GET",
515
532
  BackendRequestContext(
@@ -525,7 +542,26 @@ class AuthTools(ToolBase):
525
542
  raise_tool_error(QingflowApiError(category="workspace", message=f"Workspace {ws_id} is not accessible"))
526
543
  return workspace
527
544
 
545
+ def _selected_workspace_snapshot(
546
+ self,
547
+ *,
548
+ session_profile, # type: ignore[no-untyped-def]
549
+ backend_session, # type: ignore[no-untyped-def]
550
+ ) -> tuple[dict[str, Any] | None, str | None]:
551
+ ws_id = session_profile.selected_ws_id
552
+ if ws_id is None:
553
+ return None, None
554
+ workspace = self._fetch_workspace_with_name_fallback(
555
+ session_profile.base_url,
556
+ backend_session.token,
557
+ ws_id,
558
+ qf_version=session_profile.qf_version,
559
+ qf_version_source=session_profile.qf_version_source,
560
+ )
561
+ return workspace, self._workspace_system_version(workspace)
562
+
528
563
  def _request_route_payload(self, context: BackendRequestContext) -> dict[str, Any]:
564
+ """执行内部辅助逻辑。"""
529
565
  describe_route = getattr(self.backend, "describe_route", None)
530
566
  if callable(describe_route):
531
567
  payload = describe_route(context)
@@ -538,6 +574,7 @@ class AuthTools(ToolBase):
538
574
  }
539
575
 
540
576
  def _should_refresh_identity_metadata(self, session_profile) -> bool: # type: ignore[no-untyped-def]
577
+ """执行内部辅助逻辑。"""
541
578
  return (
542
579
  session_profile.uid == 0
543
580
  or session_profile.email is None
@@ -553,6 +590,7 @@ class AuthTools(ToolBase):
553
590
  backend_session, # type: ignore[no-untyped-def]
554
591
  context: BackendRequestContext,
555
592
  ):
593
+ """执行内部辅助逻辑。"""
556
594
  try:
557
595
  user_info, _ = self._fetch_user_info(
558
596
  session_profile.base_url,
@@ -603,6 +641,7 @@ class AuthTools(ToolBase):
603
641
  session_profile, # type: ignore[no-untyped-def]
604
642
  backend_session, # type: ignore[no-untyped-def]
605
643
  ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
644
+ """执行内部辅助逻辑。"""
606
645
  default_payload = {
607
646
  "departments": [],
608
647
  "roles": [],
@@ -647,12 +686,14 @@ class AuthTools(ToolBase):
647
686
  return payload, []
648
687
 
649
688
  def _workspace_auth(self, context: BackendRequestContext, *, ws_id: int) -> int | None:
689
+ """执行内部辅助逻辑。"""
650
690
  workspace = self._fetch_workspace_auth_from_detail(context, ws_id=ws_id)
651
691
  if workspace is not None:
652
692
  return workspace
653
693
  return self._fetch_workspace_auth_from_list(context, ws_id=ws_id)
654
694
 
655
695
  def _fetch_workspace_auth_from_detail(self, context: BackendRequestContext, *, ws_id: int) -> int | None:
696
+ """执行内部辅助逻辑。"""
656
697
  try:
657
698
  workspace = self.backend.request("GET", context, f"/user/workspace/{ws_id}")
658
699
  except QingflowApiError:
@@ -662,6 +703,7 @@ class AuthTools(ToolBase):
662
703
  return self._coerce_auth_value(workspace.get("auth"))
663
704
 
664
705
  def _fetch_workspace_auth_from_list(self, context: BackendRequestContext, *, ws_id: int) -> int | None:
706
+ """执行内部辅助逻辑。"""
665
707
  try:
666
708
  payload = self.backend.request(
667
709
  "POST",
@@ -688,6 +730,7 @@ class AuthTools(ToolBase):
688
730
  email: str | None,
689
731
  nick_name: str | None,
690
732
  ) -> dict[str, Any] | None:
733
+ """执行内部辅助逻辑。"""
691
734
  candidates: list[dict[str, Any]] = []
692
735
  for keyword in (email, nick_name):
693
736
  member = self._search_member_once(context, uid=uid, keyword=keyword)
@@ -709,12 +752,14 @@ class AuthTools(ToolBase):
709
752
  uid: int | None,
710
753
  keyword: str | None,
711
754
  ) -> dict[str, Any] | None:
755
+ """执行内部辅助逻辑。"""
712
756
  for item in self._search_member_items(context, keyword=keyword):
713
757
  if self._same_member(item, uid=uid):
714
758
  return item
715
759
  return None
716
760
 
717
761
  def _search_member_items(self, context: BackendRequestContext, *, keyword: str | None) -> list[dict[str, Any]]:
762
+ """执行内部辅助逻辑。"""
718
763
  params: dict[str, Any] = {"pageNum": 1, "pageSize": 100, "containDisable": True}
719
764
  normalized_keyword = str(keyword or "").strip()
720
765
  if normalized_keyword:
@@ -727,6 +772,7 @@ class AuthTools(ToolBase):
727
772
  return [item for item in items if isinstance(item, dict)]
728
773
 
729
774
  def _same_member(self, item: dict[str, Any], *, uid: int | None) -> bool:
775
+ """执行内部辅助逻辑。"""
730
776
  if uid is None or uid <= 0:
731
777
  return False
732
778
  for key in ("uid", "id", "userId"):
@@ -741,6 +787,7 @@ class AuthTools(ToolBase):
741
787
  return False
742
788
 
743
789
  def _compact_departments(self, member: dict[str, Any]) -> list[dict[str, Any]]:
790
+ """执行内部辅助逻辑。"""
744
791
  items: list[dict[str, Any]] = []
745
792
  seen: set[tuple[int | None, str | None]] = set()
746
793
  for depart in self._walk_nested_items(member.get("departs")):
@@ -761,6 +808,7 @@ class AuthTools(ToolBase):
761
808
  return items
762
809
 
763
810
  def _compact_roles(self, member: dict[str, Any]) -> list[dict[str, Any]]:
811
+ """执行内部辅助逻辑。"""
764
812
  items: list[dict[str, Any]] = []
765
813
  seen: set[tuple[int | None, str | None]] = set()
766
814
  for role in self._walk_nested_items(member.get("roles")):
@@ -777,6 +825,7 @@ class AuthTools(ToolBase):
777
825
  return items
778
826
 
779
827
  def _resolve_permission_level(self, auth_code: int | None) -> str | None:
828
+ """执行内部辅助逻辑。"""
780
829
  mapping = {
781
830
  2: "超级管理",
782
831
  1: "系统管理员",
@@ -786,6 +835,7 @@ class AuthTools(ToolBase):
786
835
  return mapping.get(auth_code)
787
836
 
788
837
  def _coerce_auth_value(self, value: Any) -> int | None:
838
+ """执行内部辅助逻辑。"""
789
839
  coerced = self._coerce_int(value)
790
840
  if coerced is not None:
791
841
  return coerced
@@ -804,6 +854,7 @@ class AuthTools(ToolBase):
804
854
  return None
805
855
 
806
856
  def _extract_items(self, payload: Any) -> list[Any]:
857
+ """执行内部辅助逻辑。"""
807
858
  if isinstance(payload, list):
808
859
  return payload
809
860
  if not isinstance(payload, dict):
@@ -824,6 +875,7 @@ class AuthTools(ToolBase):
824
875
  return []
825
876
 
826
877
  def _walk_nested_items(self, value: Any) -> list[Any]:
878
+ """执行内部辅助逻辑。"""
827
879
  if isinstance(value, list):
828
880
  items: list[Any] = []
829
881
  for item in value:
@@ -832,6 +884,7 @@ class AuthTools(ToolBase):
832
884
  return [value]
833
885
 
834
886
  def _coerce_int(self, value: Any) -> int | None:
887
+ """执行内部辅助逻辑。"""
835
888
  if isinstance(value, bool) or value is None:
836
889
  return None
837
890
  if isinstance(value, int):
@@ -842,6 +895,7 @@ class AuthTools(ToolBase):
842
895
  return None
843
896
 
844
897
  def _normalize_text(self, value: Any) -> str | None:
898
+ """执行内部辅助逻辑。"""
845
899
  if value is None:
846
900
  return None
847
901
  text = str(value).strip()
@@ -856,6 +910,7 @@ class AuthTools(ToolBase):
856
910
  qf_version: str | None,
857
911
  qf_version_source: str | None,
858
912
  ) -> dict[str, Any] | None:
913
+ """执行内部辅助逻辑。"""
859
914
  try:
860
915
  workspace = self._fetch_workspace(
861
916
  base_url,
@@ -891,6 +946,7 @@ class AuthTools(ToolBase):
891
946
  qf_version: str | None,
892
947
  qf_version_source: str | None,
893
948
  ) -> dict[str, Any] | None:
949
+ """执行内部辅助逻辑。"""
894
950
  payload = self.backend.request(
895
951
  "POST",
896
952
  BackendRequestContext(
@@ -915,41 +971,3 @@ class AuthTools(ToolBase):
915
971
  None,
916
972
  )
917
973
  return found if isinstance(found, dict) else None
918
-
919
- def _fetch_public_key(self, base_url: str, *, qf_version: str | None) -> str | None:
920
- # Endpoints to try (order matters, lowercase 'pubkey' is for Public Cloud)
921
- endpoints = ["/user/pubkey", "/api/user/pubkey", "/user/publicKey", "/api/user/publicKey"]
922
- for endpoint in endpoints:
923
- try:
924
- # We use unwrap=False to handle various response formats
925
- result = self.backend.public_request("GET", base_url, endpoint, unwrap=False, qf_version=qf_version)
926
- if isinstance(result, dict):
927
- # Try various common response structures
928
- data = result.get("data") or result.get("result") or result
929
- if isinstance(data, dict):
930
- # Try case-insensitive keys
931
- for key in ["pubkey", "publicKey", "pubKey"]:
932
- if key in data:
933
- return str(data[key])
934
- if isinstance(data, str) and not data.startswith("{"):
935
- return data
936
- except Exception:
937
- continue
938
-
939
- return None
940
-
941
- def _encrypt_password(self, password: str, public_key_str: str) -> str:
942
- try:
943
- if not public_key_str.startswith("-----BEGIN"):
944
- key_content = public_key_str.strip()
945
- formatted_key = f"-----BEGIN PUBLIC KEY-----\n{key_content}\n-----END PUBLIC KEY-----"
946
- else:
947
- formatted_key = public_key_str
948
-
949
- key = RSA.import_key(formatted_key)
950
- cipher = PKCS1_v1_5.new(key)
951
- encrypted = cipher.encrypt(password.encode("utf-8"))
952
- return base64.b64encode(encrypted).decode("utf-8")
953
- except Exception as e:
954
- # If encryption fails, fallback to plain text (might be a legacy or custom environment)
955
- return password