@josephyan/qingflow-cli 0.2.0-beta.984 → 0.2.0-beta.986

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 (43) 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 +47 -21
  7. package/src/qingflow_mcp/cli/commands/auth.py +14 -43
  8. package/src/qingflow_mcp/cli/commands/task.py +4 -1
  9. package/src/qingflow_mcp/cli/commands/workspace.py +0 -8
  10. package/src/qingflow_mcp/cli/formatters.py +0 -21
  11. package/src/qingflow_mcp/config.py +39 -0
  12. package/src/qingflow_mcp/errors.py +2 -2
  13. package/src/qingflow_mcp/public_surface.py +2 -6
  14. package/src/qingflow_mcp/response_trim.py +1 -8
  15. package/src/qingflow_mcp/server.py +1 -1
  16. package/src/qingflow_mcp/server_app_builder.py +4 -28
  17. package/src/qingflow_mcp/server_app_user.py +4 -28
  18. package/src/qingflow_mcp/session_store.py +31 -5
  19. package/src/qingflow_mcp/tools/ai_builder_tools.py +117 -1
  20. package/src/qingflow_mcp/tools/app_tools.py +51 -1
  21. package/src/qingflow_mcp/tools/approval_tools.py +82 -1
  22. package/src/qingflow_mcp/tools/auth_tools.py +258 -288
  23. package/src/qingflow_mcp/tools/base.py +204 -4
  24. package/src/qingflow_mcp/tools/code_block_tools.py +21 -0
  25. package/src/qingflow_mcp/tools/custom_button_tools.py +24 -1
  26. package/src/qingflow_mcp/tools/directory_tools.py +28 -1
  27. package/src/qingflow_mcp/tools/feedback_tools.py +8 -0
  28. package/src/qingflow_mcp/tools/file_tools.py +25 -1
  29. package/src/qingflow_mcp/tools/import_tools.py +40 -1
  30. package/src/qingflow_mcp/tools/navigation_tools.py +34 -1
  31. package/src/qingflow_mcp/tools/package_tools.py +37 -1
  32. package/src/qingflow_mcp/tools/portal_tools.py +28 -1
  33. package/src/qingflow_mcp/tools/qingbi_report_tools.py +38 -1
  34. package/src/qingflow_mcp/tools/record_tools.py +255 -2
  35. package/src/qingflow_mcp/tools/repository_dev_tools.py +21 -2
  36. package/src/qingflow_mcp/tools/resource_read_tools.py +23 -1
  37. package/src/qingflow_mcp/tools/role_tools.py +19 -1
  38. package/src/qingflow_mcp/tools/solution_tools.py +56 -1
  39. package/src/qingflow_mcp/tools/task_context_tools.py +205 -6
  40. package/src/qingflow_mcp/tools/task_tools.py +49 -3
  41. package/src/qingflow_mcp/tools/view_tools.py +56 -1
  42. package/src/qingflow_mcp/tools/workflow_tools.py +65 -1
  43. package/src/qingflow_mcp/tools/workspace_tools.py +14 -225
@@ -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,73 +164,64 @@ 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,
205
- )
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,
221
- )
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,
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
+ if self._should_refresh_identity_metadata(session_profile):
216
+ refreshed_profile = self._refresh_identity_metadata(
217
+ profile=profile,
218
+ session_profile=session_profile,
219
+ backend_session=backend_session,
220
+ context=context,
230
221
  )
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 {
222
+ if refreshed_profile is not None:
223
+ session_profile = refreshed_profile
224
+ response = {
243
225
  "profile": session_profile.profile,
244
226
  "base_url": session_profile.base_url,
245
227
  "qf_version": session_profile.qf_version,
@@ -249,100 +231,73 @@ class AuthTools(ToolBase):
249
231
  "nick_name": session_profile.nick_name,
250
232
  "selected_ws_id": session_profile.selected_ws_id,
251
233
  "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
234
  "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
- ),
235
+ "request_route": self._request_route_payload(context),
264
236
  }
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")
237
+ member_info, member_warnings = self._workspace_member_info(
238
+ session_profile=session_profile,
239
+ backend_session=backend_session,
240
+ )
241
+ response.update(member_info)
242
+ if member_warnings:
243
+ response["warnings"] = member_warnings
244
+ return response
307
245
 
308
- def auth_whoami(self, *, profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
246
+ session_profile = None
247
+ backend_session = None
309
248
  try:
310
249
  session_profile, backend_session, context = self._require_context(profile, require_workspace=False)
250
+ if backend_session.credential:
251
+ self._probe_token_validity(
252
+ session_profile=session_profile,
253
+ backend_session=backend_session,
254
+ )
255
+ return build_response(session_profile, backend_session, context)
311
256
  except QingflowApiError as error:
257
+ if (
258
+ error.looks_like_invalid_token()
259
+ and session_profile is not None
260
+ and backend_session is not None
261
+ and self._refresh_session_from_credential(
262
+ profile,
263
+ session_profile=session_profile,
264
+ backend_session=backend_session,
265
+ )
266
+ ):
267
+ try:
268
+ refreshed_profile, refreshed_backend_session, refreshed_context = self._require_context(
269
+ profile,
270
+ require_workspace=False,
271
+ )
272
+ return build_response(refreshed_profile, refreshed_backend_session, refreshed_context)
273
+ except QingflowApiError as refreshed_error:
274
+ self._handle_error(profile, refreshed_error)
312
275
  self._handle_error(profile, error)
313
276
  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,
277
+
278
+ def _probe_token_validity(
279
+ self,
280
+ *,
281
+ session_profile, # type: ignore[no-untyped-def]
282
+ backend_session, # type: ignore[no-untyped-def]
283
+ ) -> None:
284
+ """执行内部辅助逻辑。"""
285
+ probe_context = BackendRequestContext(
286
+ base_url=backend_session.base_url,
287
+ token=backend_session.token,
288
+ ws_id=session_profile.selected_ws_id,
289
+ qf_version=backend_session.qf_version,
290
+ qf_version_source=backend_session.qf_version_source,
339
291
  )
340
- response.update(member_info)
341
- if member_warnings:
342
- response["warnings"] = member_warnings
343
- return response
292
+ try:
293
+ self.backend.request("GET", probe_context, "/user")
294
+ except QingflowApiError as error:
295
+ if error.looks_like_invalid_token():
296
+ raise
344
297
 
298
+ @tool_cn_name("退出登录")
345
299
  def auth_logout(self, *, profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict[str, Any]:
300
+ """执行认证与会话相关逻辑。"""
346
301
  if not self.sessions.has_profile(profile):
347
302
  raise_tool_error(QingflowApiError.auth_required(profile))
348
303
  self.sessions.logout(profile, forget_persisted=forget_persisted)
@@ -353,6 +308,7 @@ class AuthTools(ToolBase):
353
308
  }
354
309
 
355
310
  def _normalize_base_url(self, base_url: str | None) -> str:
311
+ """执行内部辅助逻辑。"""
356
312
  normalized_base_url = normalize_base_url(base_url) or get_default_base_url()
357
313
  if not normalized_base_url:
358
314
  raise_tool_error(
@@ -363,12 +319,14 @@ class AuthTools(ToolBase):
363
319
  return normalized_base_url
364
320
 
365
321
  def _normalize_qf_version(self, qf_version: str | None) -> str | None:
322
+ """执行内部辅助逻辑。"""
366
323
  if qf_version is not None:
367
324
  normalized = str(qf_version).strip()
368
325
  return normalized or None
369
326
  return get_default_qf_version()
370
327
 
371
328
  def _resolve_qf_version_input(self, qf_version: str | None) -> tuple[str | None, str]:
329
+ """执行内部辅助逻辑。"""
372
330
  if qf_version is not None:
373
331
  normalized = self._normalize_qf_version(qf_version)
374
332
  return normalized, "explicit" if normalized else "unset"
@@ -377,11 +335,6 @@ class AuthTools(ToolBase):
377
335
  return normalized, "default_config"
378
336
  return None, "unset"
379
337
 
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
338
  def _resolve_backend_qf_version(
386
339
  self,
387
340
  backend_qf_version: str | None,
@@ -389,11 +342,41 @@ class AuthTools(ToolBase):
389
342
  fallback_qf_version: str | None,
390
343
  fallback_source: str,
391
344
  ) -> tuple[str | None, str]:
345
+ """执行内部辅助逻辑。"""
392
346
  if backend_qf_version:
393
347
  return backend_qf_version, "backend_response"
394
348
  return fallback_qf_version, fallback_source
395
349
 
350
+ def _fetch_auth_context(
351
+ self,
352
+ base_url: str,
353
+ credential: str,
354
+ *,
355
+ qf_version: str | None,
356
+ ) -> tuple[dict[str, Any], str | None]:
357
+ """执行内部辅助逻辑。"""
358
+ response = self.backend.public_request_with_meta(
359
+ "POST",
360
+ base_url,
361
+ "/mcp/auth/context",
362
+ json_body={"credential": credential},
363
+ qf_version=qf_version,
364
+ )
365
+ payload = self._unwrap_auth_context_payload(response.data)
366
+ return payload, response.qf_response_version
367
+
368
+ def _unwrap_auth_context_payload(self, payload: Any) -> dict[str, Any]:
369
+ """执行内部辅助逻辑。"""
370
+ if not isinstance(payload, dict):
371
+ raise_tool_error(QingflowApiError(category="auth", message="Credential context did not return a valid result"))
372
+ for key in ("data", "result"):
373
+ nested = payload.get(key)
374
+ if isinstance(nested, dict):
375
+ return nested
376
+ return payload
377
+
396
378
  def _workspace_system_version(self, workspace: Any) -> str | None:
379
+ """执行内部辅助逻辑。"""
397
380
  if not isinstance(workspace, dict):
398
381
  return None
399
382
  value = workspace.get("systemVersion")
@@ -411,6 +394,7 @@ class AuthTools(ToolBase):
411
394
  qf_version: str | None,
412
395
  qf_version_source: str | None,
413
396
  ) -> tuple[dict[str, Any], str | None]:
397
+ """执行内部辅助逻辑。"""
414
398
  request_context = BackendRequestContext(
415
399
  base_url=base_url,
416
400
  token=token,
@@ -461,6 +445,7 @@ class AuthTools(ToolBase):
461
445
  qf_version: str | None,
462
446
  qf_version_source: str | None,
463
447
  ) -> tuple[dict[str, Any] | None, str | None]:
448
+ """执行内部辅助逻辑。"""
464
449
  try:
465
450
  return self._fetch_user_info(
466
451
  base_url,
@@ -480,6 +465,7 @@ class AuthTools(ToolBase):
480
465
  qf_version: str | None,
481
466
  qf_version_source: str | None,
482
467
  ) -> tuple[dict[str, Any] | None, str | None]:
468
+ """执行内部辅助逻辑。"""
483
469
  page_response = self.backend.request_with_meta(
484
470
  "POST",
485
471
  BackendRequestContext(
@@ -510,6 +496,7 @@ class AuthTools(ToolBase):
510
496
  qf_version: str | None,
511
497
  qf_version_source: str | None,
512
498
  ) -> dict[str, Any]:
499
+ """执行内部辅助逻辑。"""
513
500
  workspace = self.backend.request(
514
501
  "GET",
515
502
  BackendRequestContext(
@@ -526,6 +513,7 @@ class AuthTools(ToolBase):
526
513
  return workspace
527
514
 
528
515
  def _request_route_payload(self, context: BackendRequestContext) -> dict[str, Any]:
516
+ """执行内部辅助逻辑。"""
529
517
  describe_route = getattr(self.backend, "describe_route", None)
530
518
  if callable(describe_route):
531
519
  payload = describe_route(context)
@@ -538,6 +526,7 @@ class AuthTools(ToolBase):
538
526
  }
539
527
 
540
528
  def _should_refresh_identity_metadata(self, session_profile) -> bool: # type: ignore[no-untyped-def]
529
+ """执行内部辅助逻辑。"""
541
530
  return (
542
531
  session_profile.uid == 0
543
532
  or session_profile.email is None
@@ -553,6 +542,7 @@ class AuthTools(ToolBase):
553
542
  backend_session, # type: ignore[no-untyped-def]
554
543
  context: BackendRequestContext,
555
544
  ):
545
+ """执行内部辅助逻辑。"""
556
546
  try:
557
547
  user_info, _ = self._fetch_user_info(
558
548
  session_profile.base_url,
@@ -603,6 +593,7 @@ class AuthTools(ToolBase):
603
593
  session_profile, # type: ignore[no-untyped-def]
604
594
  backend_session, # type: ignore[no-untyped-def]
605
595
  ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
596
+ """执行内部辅助逻辑。"""
606
597
  default_payload = {
607
598
  "departments": [],
608
599
  "roles": [],
@@ -647,12 +638,14 @@ class AuthTools(ToolBase):
647
638
  return payload, []
648
639
 
649
640
  def _workspace_auth(self, context: BackendRequestContext, *, ws_id: int) -> int | None:
641
+ """执行内部辅助逻辑。"""
650
642
  workspace = self._fetch_workspace_auth_from_detail(context, ws_id=ws_id)
651
643
  if workspace is not None:
652
644
  return workspace
653
645
  return self._fetch_workspace_auth_from_list(context, ws_id=ws_id)
654
646
 
655
647
  def _fetch_workspace_auth_from_detail(self, context: BackendRequestContext, *, ws_id: int) -> int | None:
648
+ """执行内部辅助逻辑。"""
656
649
  try:
657
650
  workspace = self.backend.request("GET", context, f"/user/workspace/{ws_id}")
658
651
  except QingflowApiError:
@@ -662,6 +655,7 @@ class AuthTools(ToolBase):
662
655
  return self._coerce_auth_value(workspace.get("auth"))
663
656
 
664
657
  def _fetch_workspace_auth_from_list(self, context: BackendRequestContext, *, ws_id: int) -> int | None:
658
+ """执行内部辅助逻辑。"""
665
659
  try:
666
660
  payload = self.backend.request(
667
661
  "POST",
@@ -688,6 +682,7 @@ class AuthTools(ToolBase):
688
682
  email: str | None,
689
683
  nick_name: str | None,
690
684
  ) -> dict[str, Any] | None:
685
+ """执行内部辅助逻辑。"""
691
686
  candidates: list[dict[str, Any]] = []
692
687
  for keyword in (email, nick_name):
693
688
  member = self._search_member_once(context, uid=uid, keyword=keyword)
@@ -709,12 +704,14 @@ class AuthTools(ToolBase):
709
704
  uid: int | None,
710
705
  keyword: str | None,
711
706
  ) -> dict[str, Any] | None:
707
+ """执行内部辅助逻辑。"""
712
708
  for item in self._search_member_items(context, keyword=keyword):
713
709
  if self._same_member(item, uid=uid):
714
710
  return item
715
711
  return None
716
712
 
717
713
  def _search_member_items(self, context: BackendRequestContext, *, keyword: str | None) -> list[dict[str, Any]]:
714
+ """执行内部辅助逻辑。"""
718
715
  params: dict[str, Any] = {"pageNum": 1, "pageSize": 100, "containDisable": True}
719
716
  normalized_keyword = str(keyword or "").strip()
720
717
  if normalized_keyword:
@@ -727,6 +724,7 @@ class AuthTools(ToolBase):
727
724
  return [item for item in items if isinstance(item, dict)]
728
725
 
729
726
  def _same_member(self, item: dict[str, Any], *, uid: int | None) -> bool:
727
+ """执行内部辅助逻辑。"""
730
728
  if uid is None or uid <= 0:
731
729
  return False
732
730
  for key in ("uid", "id", "userId"):
@@ -741,6 +739,7 @@ class AuthTools(ToolBase):
741
739
  return False
742
740
 
743
741
  def _compact_departments(self, member: dict[str, Any]) -> list[dict[str, Any]]:
742
+ """执行内部辅助逻辑。"""
744
743
  items: list[dict[str, Any]] = []
745
744
  seen: set[tuple[int | None, str | None]] = set()
746
745
  for depart in self._walk_nested_items(member.get("departs")):
@@ -761,6 +760,7 @@ class AuthTools(ToolBase):
761
760
  return items
762
761
 
763
762
  def _compact_roles(self, member: dict[str, Any]) -> list[dict[str, Any]]:
763
+ """执行内部辅助逻辑。"""
764
764
  items: list[dict[str, Any]] = []
765
765
  seen: set[tuple[int | None, str | None]] = set()
766
766
  for role in self._walk_nested_items(member.get("roles")):
@@ -777,6 +777,7 @@ class AuthTools(ToolBase):
777
777
  return items
778
778
 
779
779
  def _resolve_permission_level(self, auth_code: int | None) -> str | None:
780
+ """执行内部辅助逻辑。"""
780
781
  mapping = {
781
782
  2: "超级管理",
782
783
  1: "系统管理员",
@@ -786,6 +787,7 @@ class AuthTools(ToolBase):
786
787
  return mapping.get(auth_code)
787
788
 
788
789
  def _coerce_auth_value(self, value: Any) -> int | None:
790
+ """执行内部辅助逻辑。"""
789
791
  coerced = self._coerce_int(value)
790
792
  if coerced is not None:
791
793
  return coerced
@@ -804,6 +806,7 @@ class AuthTools(ToolBase):
804
806
  return None
805
807
 
806
808
  def _extract_items(self, payload: Any) -> list[Any]:
809
+ """执行内部辅助逻辑。"""
807
810
  if isinstance(payload, list):
808
811
  return payload
809
812
  if not isinstance(payload, dict):
@@ -824,6 +827,7 @@ class AuthTools(ToolBase):
824
827
  return []
825
828
 
826
829
  def _walk_nested_items(self, value: Any) -> list[Any]:
830
+ """执行内部辅助逻辑。"""
827
831
  if isinstance(value, list):
828
832
  items: list[Any] = []
829
833
  for item in value:
@@ -832,6 +836,7 @@ class AuthTools(ToolBase):
832
836
  return [value]
833
837
 
834
838
  def _coerce_int(self, value: Any) -> int | None:
839
+ """执行内部辅助逻辑。"""
835
840
  if isinstance(value, bool) or value is None:
836
841
  return None
837
842
  if isinstance(value, int):
@@ -842,6 +847,7 @@ class AuthTools(ToolBase):
842
847
  return None
843
848
 
844
849
  def _normalize_text(self, value: Any) -> str | None:
850
+ """执行内部辅助逻辑。"""
845
851
  if value is None:
846
852
  return None
847
853
  text = str(value).strip()
@@ -856,6 +862,7 @@ class AuthTools(ToolBase):
856
862
  qf_version: str | None,
857
863
  qf_version_source: str | None,
858
864
  ) -> dict[str, Any] | None:
865
+ """执行内部辅助逻辑。"""
859
866
  try:
860
867
  workspace = self._fetch_workspace(
861
868
  base_url,
@@ -891,6 +898,7 @@ class AuthTools(ToolBase):
891
898
  qf_version: str | None,
892
899
  qf_version_source: str | None,
893
900
  ) -> dict[str, Any] | None:
901
+ """执行内部辅助逻辑。"""
894
902
  payload = self.backend.request(
895
903
  "POST",
896
904
  BackendRequestContext(
@@ -915,41 +923,3 @@ class AuthTools(ToolBase):
915
923
  None,
916
924
  )
917
925
  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