@josephyan/qingflow-cli 0.2.0-beta.988 → 0.2.0-beta.990
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 +7 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/cli/commands/auth.py +128 -0
- package/src/qingflow_mcp/cli/commands/record.py +5 -5
- package/src/qingflow_mcp/cli/commands/task.py +3 -3
- package/src/qingflow_mcp/cli/formatters.py +7 -0
- package/src/qingflow_mcp/cli/oauth_login.py +626 -0
- package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
- package/src/qingflow_mcp/errors.py +2 -2
- package/src/qingflow_mcp/id_utils.py +49 -0
- package/src/qingflow_mcp/public_surface.py +1 -0
- package/src/qingflow_mcp/response_trim.py +10 -0
- package/src/qingflow_mcp/tools/auth_tools.py +130 -0
- package/src/qingflow_mcp/tools/base.py +1 -1
- package/src/qingflow_mcp/tools/code_block_tools.py +2 -2
- package/src/qingflow_mcp/tools/record_tools.py +96 -55
- package/src/qingflow_mcp/tools/task_context_tools.py +55 -44
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from Crypto.Cipher import PKCS1_v1_5
|
|
8
|
+
from Crypto.PublicKey import RSA
|
|
9
|
+
|
|
10
|
+
from ..backend_client import BackendClient
|
|
11
|
+
from ..config import get_default_base_url, get_timeout_seconds, normalize_base_url
|
|
12
|
+
from ..errors import QingflowApiError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class QingflowNativeLoginResult:
|
|
17
|
+
token: str
|
|
18
|
+
user_info: dict[str, Any]
|
|
19
|
+
login_token: str | None = None
|
|
20
|
+
flow: str = "qingflow_password"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class QingflowNativeLoginHelper:
|
|
24
|
+
def __init__(self, *, backend: BackendClient | None = None) -> None:
|
|
25
|
+
self._owns_backend = backend is None
|
|
26
|
+
self._backend = backend or BackendClient(timeout=get_timeout_seconds())
|
|
27
|
+
|
|
28
|
+
def close(self) -> None:
|
|
29
|
+
if self._owns_backend:
|
|
30
|
+
self._backend.close()
|
|
31
|
+
|
|
32
|
+
def login_with_password(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
base_url: str | None,
|
|
36
|
+
email: str,
|
|
37
|
+
password: str,
|
|
38
|
+
) -> QingflowNativeLoginResult:
|
|
39
|
+
normalized_base_url = normalize_base_url(base_url) or get_default_base_url()
|
|
40
|
+
normalized_email = str(email or "").strip()
|
|
41
|
+
normalized_password = str(password or "")
|
|
42
|
+
if not normalized_base_url:
|
|
43
|
+
raise QingflowApiError.config_error("base_url is required or configure default_base_url")
|
|
44
|
+
if not normalized_email:
|
|
45
|
+
raise QingflowApiError.config_error("email is required for Qingflow account login")
|
|
46
|
+
if not normalized_password:
|
|
47
|
+
raise QingflowApiError.config_error("password is required for Qingflow account login")
|
|
48
|
+
|
|
49
|
+
pubkey_payload = self._backend.public_request("GET", normalized_base_url, "/user/pubkey", qf_version=None)
|
|
50
|
+
pubkey = self._extract_pubkey(pubkey_payload)
|
|
51
|
+
encrypted_password = _encrypt_password(normalized_password, pubkey)
|
|
52
|
+
login_payload = self._backend.public_request(
|
|
53
|
+
"POST",
|
|
54
|
+
normalized_base_url,
|
|
55
|
+
"/user/login",
|
|
56
|
+
json_body={"email": normalized_email, "password": encrypted_password},
|
|
57
|
+
qf_version=None,
|
|
58
|
+
)
|
|
59
|
+
if not isinstance(login_payload, dict):
|
|
60
|
+
raise QingflowApiError(category="auth", message="Qingflow login did not return a valid response")
|
|
61
|
+
|
|
62
|
+
token = str(login_payload.get("token") or "").strip()
|
|
63
|
+
login_token = str(login_payload.get("loginToken") or "").strip() or None
|
|
64
|
+
if not token:
|
|
65
|
+
if login_token:
|
|
66
|
+
raise QingflowApiError(
|
|
67
|
+
category="auth",
|
|
68
|
+
message=(
|
|
69
|
+
"Qingflow account login requires additional security verification. "
|
|
70
|
+
"CLI password login currently does not complete the loginToken verification step."
|
|
71
|
+
),
|
|
72
|
+
details={"login_token_present": True},
|
|
73
|
+
)
|
|
74
|
+
raise QingflowApiError(
|
|
75
|
+
category="auth",
|
|
76
|
+
message="Qingflow login succeeded but did not return a token",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
user_info = login_payload.get("userInfo")
|
|
80
|
+
if not isinstance(user_info, dict):
|
|
81
|
+
user_info = {}
|
|
82
|
+
return QingflowNativeLoginResult(
|
|
83
|
+
token=token,
|
|
84
|
+
login_token=login_token,
|
|
85
|
+
user_info=user_info,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _extract_pubkey(self, payload: Any) -> str:
|
|
89
|
+
if not isinstance(payload, dict):
|
|
90
|
+
raise QingflowApiError(category="auth", message="Qingflow pubkey response is invalid")
|
|
91
|
+
pubkey = str(payload.get("pubkey") or "").strip()
|
|
92
|
+
if not pubkey:
|
|
93
|
+
raise QingflowApiError(category="auth", message="Qingflow pubkey response did not include pubkey")
|
|
94
|
+
return pubkey
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def login_with_qingflow_password(
|
|
98
|
+
*,
|
|
99
|
+
base_url: str | None,
|
|
100
|
+
email: str,
|
|
101
|
+
password: str,
|
|
102
|
+
) -> QingflowNativeLoginResult:
|
|
103
|
+
helper = QingflowNativeLoginHelper()
|
|
104
|
+
try:
|
|
105
|
+
return helper.login_with_password(base_url=base_url, email=email, password=password)
|
|
106
|
+
finally:
|
|
107
|
+
helper.close()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _encrypt_password(password: str, pubkey: str) -> str:
|
|
111
|
+
public_key = RSA.import_key(
|
|
112
|
+
"-----BEGIN PUBLIC KEY-----\n" + pubkey.strip() + "\n-----END PUBLIC KEY-----\n"
|
|
113
|
+
)
|
|
114
|
+
cipher = PKCS1_v1_5.new(public_key)
|
|
115
|
+
encrypted = cipher.encrypt(password.encode("utf-8"))
|
|
116
|
+
return base64.b64encode(encrypted).decode("ascii")
|
|
@@ -43,14 +43,14 @@ class QingflowApiError(Exception):
|
|
|
43
43
|
def auth_required(cls, profile: str) -> "QingflowApiError":
|
|
44
44
|
return cls(
|
|
45
45
|
category="auth",
|
|
46
|
-
message=f"Profile '{profile}' is not logged in. Run auth_use_credential first.",
|
|
46
|
+
message=f"Profile '{profile}' is not logged in. Run auth login or auth_use_credential first.",
|
|
47
47
|
)
|
|
48
48
|
|
|
49
49
|
@classmethod
|
|
50
50
|
def workspace_not_selected(cls, profile: str) -> "QingflowApiError":
|
|
51
51
|
return cls(
|
|
52
52
|
category="workspace",
|
|
53
|
-
message=f"WORKSPACE_NOT_SELECTED: profile '{profile}' has no workspace from auth context. Re-run auth_use_credential.",
|
|
53
|
+
message=f"WORKSPACE_NOT_SELECTED: profile '{profile}' has no workspace from auth context. Re-run auth login or auth_use_credential.",
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
@classmethod
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .errors import QingflowApiError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
JS_MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def stringify_backend_id(value: Any) -> str | None:
|
|
12
|
+
"""Return an exact public id string for backend-originated identifiers."""
|
|
13
|
+
if value in (None, ""):
|
|
14
|
+
return None
|
|
15
|
+
if isinstance(value, bool):
|
|
16
|
+
return None
|
|
17
|
+
text = str(value).strip()
|
|
18
|
+
return text or None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_positive_id_text(value: Any, *, field_name: str) -> str:
|
|
22
|
+
"""Normalize a user-supplied id while rejecting JS-unsafe numeric input."""
|
|
23
|
+
if value in (None, "") or isinstance(value, bool):
|
|
24
|
+
raise QingflowApiError.config_error(f"{field_name} must be positive")
|
|
25
|
+
if isinstance(value, int):
|
|
26
|
+
if value <= 0:
|
|
27
|
+
raise QingflowApiError.config_error(f"{field_name} must be positive")
|
|
28
|
+
if value > JS_MAX_SAFE_INTEGER:
|
|
29
|
+
raise QingflowApiError.config_error(
|
|
30
|
+
f"{field_name} exceeds JavaScript's safe integer range; pass it as a string to avoid precision loss"
|
|
31
|
+
)
|
|
32
|
+
return str(value)
|
|
33
|
+
if isinstance(value, str):
|
|
34
|
+
text = value.strip()
|
|
35
|
+
if not text.isdecimal() or int(text) <= 0:
|
|
36
|
+
raise QingflowApiError.config_error(f"{field_name} must be a positive integer string")
|
|
37
|
+
return text
|
|
38
|
+
raise QingflowApiError.config_error(f"{field_name} must be a positive integer string")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize_positive_id_int(value: Any, *, field_name: str) -> int:
|
|
42
|
+
"""Normalize an id to Python int after the public boundary preserves it as text."""
|
|
43
|
+
return int(normalize_positive_id_text(value, field_name=field_name))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ids_equal(left: Any, right: Any) -> bool:
|
|
47
|
+
left_text = stringify_backend_id(left)
|
|
48
|
+
right_text = stringify_backend_id(right)
|
|
49
|
+
return left_text is not None and right_text is not None and left_text == right_text
|
|
@@ -30,6 +30,7 @@ def tool_key(domain: str, tool_name: str) -> str:
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
33
|
+
PublicToolSpec(USER_DOMAIN, "auth_login", cli_route=("auth", "login"), mcp_public=False),
|
|
33
34
|
PublicToolSpec(USER_DOMAIN, "auth_use_credential", ("auth_use_credential",), ("auth", "use-credential")),
|
|
34
35
|
PublicToolSpec(USER_DOMAIN, "auth_whoami", ("auth_whoami",), ("auth", "whoami")),
|
|
35
36
|
PublicToolSpec(USER_DOMAIN, "auth_logout", ("auth_logout",), ("auth", "logout")),
|
|
@@ -263,6 +263,15 @@ def _trim_workspace_list(payload: JSONObject) -> None:
|
|
|
263
263
|
_trim_item_list(page, "list", allowed=("wsId", "workspaceName", "remark"))
|
|
264
264
|
|
|
265
265
|
|
|
266
|
+
def _trim_workspace_get(payload: JSONObject) -> None:
|
|
267
|
+
workspace = payload.get("workspace")
|
|
268
|
+
if isinstance(workspace, dict):
|
|
269
|
+
payload["workspace"] = _pick(
|
|
270
|
+
workspace,
|
|
271
|
+
allowed=("wsId", "workspaceName", "remark", "systemVersion", "auth"),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
266
275
|
def _trim_app_search_like(payload: JSONObject) -> None:
|
|
267
276
|
payload.pop("apps", None)
|
|
268
277
|
_trim_item_list(payload, "items", allowed=("app_key", "app_name", "package_name"))
|
|
@@ -728,6 +737,7 @@ def _register_policy(domains: tuple[str, ...], names: tuple[str, ...], transform
|
|
|
728
737
|
_register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("auth_use_credential", "auth_whoami"), _trim_auth_payload)
|
|
729
738
|
_register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("auth_logout",), _trim_auth_logout)
|
|
730
739
|
_register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("workspace_list",), _trim_workspace_list)
|
|
740
|
+
_register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("workspace_get",), _trim_workspace_get)
|
|
731
741
|
_register_policy((USER_DOMAIN,), ("app_list", "app_search"), _trim_app_search_like)
|
|
732
742
|
_register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("app_get",), _trim_app_get)
|
|
733
743
|
_register_policy((BUILDER_DOMAIN,), ("app_repair_code_blocks",), _trim_builder_list_like)
|
|
@@ -164,6 +164,136 @@ class AuthTools(ToolBase):
|
|
|
164
164
|
),
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
def auth_use_token(
|
|
168
|
+
self,
|
|
169
|
+
*,
|
|
170
|
+
profile: str = DEFAULT_PROFILE,
|
|
171
|
+
base_url: str | None = None,
|
|
172
|
+
qf_version: str | None = None,
|
|
173
|
+
token: str | None = None,
|
|
174
|
+
login_token: str | None = None,
|
|
175
|
+
persist: bool = False,
|
|
176
|
+
user_info: dict[str, Any] | None = None,
|
|
177
|
+
) -> dict[str, Any]:
|
|
178
|
+
"""使用已获得的 Qingflow token 建立本地会话。"""
|
|
179
|
+
normalized_base_url = self._normalize_base_url(base_url)
|
|
180
|
+
normalized_qf_version, qf_version_source = self._resolve_qf_version_input(qf_version)
|
|
181
|
+
normalized_token = self._normalize_text(token)
|
|
182
|
+
normalized_login_token = self._normalize_text(login_token)
|
|
183
|
+
if not normalized_token:
|
|
184
|
+
raise_tool_error(QingflowApiError.config_error("token is required"))
|
|
185
|
+
|
|
186
|
+
resolved_user_info = user_info if isinstance(user_info, dict) else None
|
|
187
|
+
response_qf_version: str | None = None
|
|
188
|
+
if resolved_user_info is None:
|
|
189
|
+
resolved_user_info, response_qf_version = self._fetch_user_info(
|
|
190
|
+
normalized_base_url,
|
|
191
|
+
normalized_token,
|
|
192
|
+
None,
|
|
193
|
+
qf_version=normalized_qf_version,
|
|
194
|
+
qf_version_source=qf_version_source,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
last_workspace = resolved_user_info.get("lastWsInfo")
|
|
198
|
+
selected_ws_id = self._coerce_positive_int(
|
|
199
|
+
last_workspace.get("wsId") if isinstance(last_workspace, dict) else None
|
|
200
|
+
)
|
|
201
|
+
selected_ws_name = self._normalize_text(
|
|
202
|
+
(last_workspace.get("wsName") if isinstance(last_workspace, dict) else None)
|
|
203
|
+
or (last_workspace.get("workspaceName") if isinstance(last_workspace, dict) else None)
|
|
204
|
+
or (last_workspace.get("remark") if isinstance(last_workspace, dict) else None)
|
|
205
|
+
)
|
|
206
|
+
workspace_qf_version = (
|
|
207
|
+
self._workspace_system_version(last_workspace) if isinstance(last_workspace, dict) else None
|
|
208
|
+
)
|
|
209
|
+
if selected_ws_id is None:
|
|
210
|
+
fallback_workspace, fallback_qf_version = self._fetch_first_workspace(
|
|
211
|
+
normalized_base_url,
|
|
212
|
+
normalized_token,
|
|
213
|
+
qf_version=normalized_qf_version,
|
|
214
|
+
qf_version_source=qf_version_source,
|
|
215
|
+
)
|
|
216
|
+
if isinstance(fallback_workspace, dict):
|
|
217
|
+
selected_ws_id = self._coerce_positive_int(fallback_workspace.get("wsId"))
|
|
218
|
+
selected_ws_name = self._normalize_text(
|
|
219
|
+
fallback_workspace.get("workspaceName")
|
|
220
|
+
or fallback_workspace.get("wsName")
|
|
221
|
+
or fallback_workspace.get("remark")
|
|
222
|
+
) or selected_ws_name
|
|
223
|
+
workspace_qf_version = self._workspace_system_version(fallback_workspace) or fallback_qf_version
|
|
224
|
+
elif selected_ws_name is None or workspace_qf_version is None:
|
|
225
|
+
workspace = self._fetch_workspace_with_name_fallback(
|
|
226
|
+
normalized_base_url,
|
|
227
|
+
normalized_token,
|
|
228
|
+
selected_ws_id,
|
|
229
|
+
qf_version=normalized_qf_version,
|
|
230
|
+
qf_version_source=qf_version_source,
|
|
231
|
+
)
|
|
232
|
+
if isinstance(workspace, dict):
|
|
233
|
+
selected_ws_name = self._normalize_text(
|
|
234
|
+
workspace.get("workspaceName")
|
|
235
|
+
or workspace.get("wsName")
|
|
236
|
+
or workspace.get("remark")
|
|
237
|
+
) or selected_ws_name
|
|
238
|
+
workspace_qf_version = self._workspace_system_version(workspace) or workspace_qf_version
|
|
239
|
+
|
|
240
|
+
if workspace_qf_version is not None:
|
|
241
|
+
resolved_qf_version, resolved_qf_version_source = workspace_qf_version, "workspace_system_version"
|
|
242
|
+
else:
|
|
243
|
+
resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
|
|
244
|
+
response_qf_version,
|
|
245
|
+
fallback_qf_version=normalized_qf_version,
|
|
246
|
+
fallback_source=qf_version_source,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
uid = self._coerce_positive_int(resolved_user_info.get("uid"))
|
|
250
|
+
if uid is None:
|
|
251
|
+
raise_tool_error(QingflowApiError(category="auth", message="Token validation did not return valid user info"))
|
|
252
|
+
|
|
253
|
+
session_profile = self.sessions.save_session(
|
|
254
|
+
profile=profile,
|
|
255
|
+
base_url=normalized_base_url,
|
|
256
|
+
qf_version=resolved_qf_version,
|
|
257
|
+
qf_version_source=resolved_qf_version_source,
|
|
258
|
+
token=normalized_token,
|
|
259
|
+
login_token=normalized_login_token,
|
|
260
|
+
credential=None,
|
|
261
|
+
uid=uid,
|
|
262
|
+
email=self._normalize_text(resolved_user_info.get("email")),
|
|
263
|
+
nick_name=self._normalize_text(
|
|
264
|
+
resolved_user_info.get("nickName")
|
|
265
|
+
or resolved_user_info.get("displayName")
|
|
266
|
+
or resolved_user_info.get("name")
|
|
267
|
+
),
|
|
268
|
+
persist=persist,
|
|
269
|
+
)
|
|
270
|
+
if selected_ws_id is not None:
|
|
271
|
+
session_profile = self.sessions.select_workspace(profile, ws_id=selected_ws_id, ws_name=selected_ws_name)
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
"profile": session_profile.profile,
|
|
275
|
+
"base_url": session_profile.base_url,
|
|
276
|
+
"qf_version": session_profile.qf_version,
|
|
277
|
+
"qf_version_source": session_profile.qf_version_source,
|
|
278
|
+
"uid": session_profile.uid,
|
|
279
|
+
"email": session_profile.email,
|
|
280
|
+
"nick_name": session_profile.nick_name,
|
|
281
|
+
"selected_ws_id": session_profile.selected_ws_id,
|
|
282
|
+
"selected_ws_name": session_profile.selected_ws_name,
|
|
283
|
+
"suggested_ws_id": session_profile.selected_ws_id,
|
|
284
|
+
"suggested_ws_name": session_profile.selected_ws_name,
|
|
285
|
+
"persisted": session_profile.persisted,
|
|
286
|
+
"request_route": self._request_route_payload(
|
|
287
|
+
BackendRequestContext(
|
|
288
|
+
base_url=session_profile.base_url,
|
|
289
|
+
token=normalized_token,
|
|
290
|
+
ws_id=session_profile.selected_ws_id,
|
|
291
|
+
qf_version=session_profile.qf_version,
|
|
292
|
+
qf_version_source=session_profile.qf_version_source,
|
|
293
|
+
)
|
|
294
|
+
),
|
|
295
|
+
}
|
|
296
|
+
|
|
167
297
|
def _resolve_mcporter_auth_inputs(self, *, base_url: str | None, credential: str | None) -> tuple[str | None, str]:
|
|
168
298
|
"""从参数或 mcporter 配置解析登录所需 base_url 与 credential。"""
|
|
169
299
|
normalized_base_url = self._normalize_text(base_url)
|
|
@@ -100,7 +100,7 @@ class ToolBase:
|
|
|
100
100
|
self.sessions.invalidate(profile)
|
|
101
101
|
error = QingflowApiError(
|
|
102
102
|
category="auth",
|
|
103
|
-
message=f"Qingflow session for profile '{profile}' has expired. Run auth_use_credential again.",
|
|
103
|
+
message=f"Qingflow session for profile '{profile}' has expired. Run auth login or auth_use_credential again.",
|
|
104
104
|
backend_code=error.backend_code,
|
|
105
105
|
request_id=error.request_id,
|
|
106
106
|
http_status=error.http_status,
|
|
@@ -91,7 +91,7 @@ class CodeBlockTools(RecordTools):
|
|
|
91
91
|
def record_code_block_run(
|
|
92
92
|
profile: str = DEFAULT_PROFILE,
|
|
93
93
|
app_key: str = "",
|
|
94
|
-
record_id:
|
|
94
|
+
record_id: str = "",
|
|
95
95
|
code_block_field: str = "",
|
|
96
96
|
role: int = 1,
|
|
97
97
|
workflow_node_id: int | None = None,
|
|
@@ -197,7 +197,7 @@ class CodeBlockTools(RecordTools):
|
|
|
197
197
|
*,
|
|
198
198
|
profile: str,
|
|
199
199
|
app_key: str,
|
|
200
|
-
record_id: int,
|
|
200
|
+
record_id: int | str,
|
|
201
201
|
code_block_field: str,
|
|
202
202
|
role: int = 1,
|
|
203
203
|
workflow_node_id: int | None = None,
|