@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.
- package/README.md +2 -2
- package/docs/local-agent-install.md +70 -11
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/builder_facade/service.py +47 -21
- package/src/qingflow_mcp/cli/commands/auth.py +14 -43
- package/src/qingflow_mcp/cli/commands/task.py +4 -1
- package/src/qingflow_mcp/cli/commands/workspace.py +0 -8
- package/src/qingflow_mcp/cli/formatters.py +0 -21
- package/src/qingflow_mcp/config.py +39 -0
- package/src/qingflow_mcp/errors.py +2 -2
- package/src/qingflow_mcp/public_surface.py +2 -6
- package/src/qingflow_mcp/response_trim.py +1 -8
- package/src/qingflow_mcp/server.py +1 -1
- package/src/qingflow_mcp/server_app_builder.py +4 -28
- package/src/qingflow_mcp/server_app_user.py +4 -28
- package/src/qingflow_mcp/session_store.py +31 -5
- package/src/qingflow_mcp/tools/ai_builder_tools.py +117 -1
- package/src/qingflow_mcp/tools/app_tools.py +51 -1
- package/src/qingflow_mcp/tools/approval_tools.py +82 -1
- package/src/qingflow_mcp/tools/auth_tools.py +258 -288
- package/src/qingflow_mcp/tools/base.py +204 -4
- package/src/qingflow_mcp/tools/code_block_tools.py +21 -0
- package/src/qingflow_mcp/tools/custom_button_tools.py +24 -1
- package/src/qingflow_mcp/tools/directory_tools.py +28 -1
- package/src/qingflow_mcp/tools/feedback_tools.py +8 -0
- package/src/qingflow_mcp/tools/file_tools.py +25 -1
- package/src/qingflow_mcp/tools/import_tools.py +40 -1
- package/src/qingflow_mcp/tools/navigation_tools.py +34 -1
- package/src/qingflow_mcp/tools/package_tools.py +37 -1
- package/src/qingflow_mcp/tools/portal_tools.py +28 -1
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +38 -1
- package/src/qingflow_mcp/tools/record_tools.py +255 -2
- package/src/qingflow_mcp/tools/repository_dev_tools.py +21 -2
- package/src/qingflow_mcp/tools/resource_read_tools.py +23 -1
- package/src/qingflow_mcp/tools/role_tools.py +19 -1
- package/src/qingflow_mcp/tools/solution_tools.py +56 -1
- package/src/qingflow_mcp/tools/task_context_tools.py +205 -6
- package/src/qingflow_mcp/tools/task_tools.py +49 -3
- package/src/qingflow_mcp/tools/view_tools.py +56 -1
- package/src/qingflow_mcp/tools/workflow_tools.py +65 -1
- package/src/qingflow_mcp/tools/workspace_tools.py +14 -225
|
@@ -1,154 +1,145 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
44
|
-
ws_id: int | None = None,
|
|
46
|
+
credential: str = "",
|
|
45
47
|
persist: bool = False,
|
|
46
48
|
) -> dict[str, Any]:
|
|
47
|
-
return self.
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
persist: bool,
|
|
82
|
+
credential: str | None = None,
|
|
83
|
+
persist: bool = False,
|
|
66
84
|
) -> dict[str, Any]:
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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.
|
|
109
|
-
"
|
|
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="
|
|
115
|
-
|
|
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
|
-
|
|
123
|
-
if
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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=
|
|
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=
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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":
|
|
163
|
-
"suggested_ws_name":
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|