@josephyan/qingflow-cli 0.2.0-beta.55
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 +30 -0
- package/docs/local-agent-install.md +235 -0
- package/entry_point.py +13 -0
- package/npm/bin/qingflow.mjs +5 -0
- package/npm/lib/runtime.mjs +204 -0
- package/npm/scripts/postinstall.mjs +16 -0
- package/package.json +34 -0
- package/pyproject.toml +67 -0
- package/qingflow +15 -0
- package/src/qingflow_mcp/__init__.py +5 -0
- package/src/qingflow_mcp/__main__.py +5 -0
- package/src/qingflow_mcp/backend_client.py +547 -0
- package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
- package/src/qingflow_mcp/builder_facade/models.py +985 -0
- package/src/qingflow_mcp/builder_facade/service.py +8243 -0
- package/src/qingflow_mcp/cli/__init__.py +1 -0
- package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
- package/src/qingflow_mcp/cli/commands/app.py +40 -0
- package/src/qingflow_mcp/cli/commands/auth.py +78 -0
- package/src/qingflow_mcp/cli/commands/builder.py +184 -0
- package/src/qingflow_mcp/cli/commands/common.py +47 -0
- package/src/qingflow_mcp/cli/commands/imports.py +86 -0
- package/src/qingflow_mcp/cli/commands/record.py +202 -0
- package/src/qingflow_mcp/cli/commands/task.py +87 -0
- package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
- package/src/qingflow_mcp/cli/context.py +48 -0
- package/src/qingflow_mcp/cli/formatters.py +269 -0
- package/src/qingflow_mcp/cli/json_io.py +50 -0
- package/src/qingflow_mcp/cli/main.py +147 -0
- package/src/qingflow_mcp/config.py +221 -0
- package/src/qingflow_mcp/errors.py +66 -0
- package/src/qingflow_mcp/import_store.py +121 -0
- package/src/qingflow_mcp/json_types.py +18 -0
- package/src/qingflow_mcp/list_type_labels.py +76 -0
- package/src/qingflow_mcp/server.py +211 -0
- package/src/qingflow_mcp/server_app_builder.py +387 -0
- package/src/qingflow_mcp/server_app_user.py +317 -0
- package/src/qingflow_mcp/session_store.py +289 -0
- package/src/qingflow_mcp/solution/__init__.py +6 -0
- package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
- package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
- package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +466 -0
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
- package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
- package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
- package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
- package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
- package/src/qingflow_mcp/solution/design_session.py +222 -0
- package/src/qingflow_mcp/solution/design_store.py +100 -0
- package/src/qingflow_mcp/solution/executor.py +2339 -0
- package/src/qingflow_mcp/solution/normalizer.py +23 -0
- package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
- package/src/qingflow_mcp/solution/run_store.py +244 -0
- package/src/qingflow_mcp/solution/spec_models.py +853 -0
- package/src/qingflow_mcp/tools/__init__.py +1 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +2063 -0
- package/src/qingflow_mcp/tools/app_tools.py +850 -0
- package/src/qingflow_mcp/tools/approval_tools.py +833 -0
- package/src/qingflow_mcp/tools/auth_tools.py +697 -0
- package/src/qingflow_mcp/tools/base.py +81 -0
- package/src/qingflow_mcp/tools/code_block_tools.py +679 -0
- package/src/qingflow_mcp/tools/directory_tools.py +648 -0
- package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
- package/src/qingflow_mcp/tools/file_tools.py +385 -0
- package/src/qingflow_mcp/tools/import_tools.py +1971 -0
- package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
- package/src/qingflow_mcp/tools/package_tools.py +240 -0
- package/src/qingflow_mcp/tools/portal_tools.py +131 -0
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +269 -0
- package/src/qingflow_mcp/tools/record_tools.py +12739 -0
- package/src/qingflow_mcp/tools/role_tools.py +94 -0
- package/src/qingflow_mcp/tools/solution_tools.py +3887 -0
- package/src/qingflow_mcp/tools/task_context_tools.py +1423 -0
- package/src/qingflow_mcp/tools/task_tools.py +843 -0
- package/src/qingflow_mcp/tools/view_tools.py +280 -0
- package/src/qingflow_mcp/tools/workflow_tools.py +312 -0
- package/src/qingflow_mcp/tools/workspace_tools.py +219 -0
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from mcp.server.fastmcp import FastMCP
|
|
7
|
+
from Crypto.PublicKey import RSA
|
|
8
|
+
from Crypto.Cipher import PKCS1_v1_5
|
|
9
|
+
|
|
10
|
+
from ..backend_client import BackendRequestContext, BackendResponse
|
|
11
|
+
from ..config import (
|
|
12
|
+
DEFAULT_PROFILE,
|
|
13
|
+
get_default_base_url,
|
|
14
|
+
get_default_qf_version,
|
|
15
|
+
normalize_base_url,
|
|
16
|
+
)
|
|
17
|
+
from ..errors import QingflowApiError, raise_tool_error
|
|
18
|
+
from ..session_store import SessionStore
|
|
19
|
+
from .base import ToolBase
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuthTools(ToolBase):
|
|
23
|
+
def __init__(self, sessions: SessionStore, backend) -> None:
|
|
24
|
+
super().__init__(sessions, backend)
|
|
25
|
+
|
|
26
|
+
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(
|
|
40
|
+
profile: str = DEFAULT_PROFILE,
|
|
41
|
+
base_url: str | None = None,
|
|
42
|
+
qf_version: str | None = None,
|
|
43
|
+
token: str = "",
|
|
44
|
+
ws_id: int | None = None,
|
|
45
|
+
persist: bool = False,
|
|
46
|
+
) -> 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)
|
|
48
|
+
|
|
49
|
+
@mcp.tool()
|
|
50
|
+
def auth_whoami(profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
|
|
51
|
+
return self.auth_whoami(profile=profile)
|
|
52
|
+
|
|
53
|
+
@mcp.tool()
|
|
54
|
+
def auth_logout(profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict[str, Any]:
|
|
55
|
+
return self.auth_logout(profile=profile, forget_persisted=forget_persisted)
|
|
56
|
+
|
|
57
|
+
def auth_login(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
profile: str = DEFAULT_PROFILE,
|
|
61
|
+
base_url: str | None = None,
|
|
62
|
+
qf_version: str | None = None,
|
|
63
|
+
email: str,
|
|
64
|
+
password: str,
|
|
65
|
+
persist: bool,
|
|
66
|
+
) -> dict[str, Any]:
|
|
67
|
+
normalized_base_url = self._normalize_base_url(base_url)
|
|
68
|
+
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:
|
|
107
|
+
raise_tool_error(
|
|
108
|
+
QingflowApiError.not_supported(
|
|
109
|
+
"Current environment requires an additional login challenge. Qingflow MCP v1 only supports direct token login."
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
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
|
|
116
|
+
resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
|
|
117
|
+
detected_qf_version,
|
|
118
|
+
fallback_qf_version=normalized_qf_version,
|
|
119
|
+
fallback_source=qf_version_source,
|
|
120
|
+
)
|
|
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 {}
|
|
140
|
+
session_profile = self.sessions.save_session(
|
|
141
|
+
profile=profile,
|
|
142
|
+
base_url=normalized_base_url,
|
|
143
|
+
qf_version=resolved_qf_version,
|
|
144
|
+
qf_version_source=resolved_qf_version_source,
|
|
145
|
+
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"),
|
|
150
|
+
persist=persist,
|
|
151
|
+
)
|
|
152
|
+
return {
|
|
153
|
+
"profile": session_profile.profile,
|
|
154
|
+
"base_url": session_profile.base_url,
|
|
155
|
+
"qf_version": session_profile.qf_version,
|
|
156
|
+
"qf_version_source": session_profile.qf_version_source,
|
|
157
|
+
"uid": session_profile.uid,
|
|
158
|
+
"email": session_profile.email,
|
|
159
|
+
"nick_name": session_profile.nick_name,
|
|
160
|
+
"selected_ws_id": session_profile.selected_ws_id,
|
|
161
|
+
"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"),
|
|
164
|
+
"persisted": session_profile.persisted,
|
|
165
|
+
"request_route": self._request_route_payload(
|
|
166
|
+
BackendRequestContext(
|
|
167
|
+
base_url=session_profile.base_url,
|
|
168
|
+
token=token,
|
|
169
|
+
ws_id=session_profile.selected_ws_id,
|
|
170
|
+
qf_version=session_profile.qf_version,
|
|
171
|
+
qf_version_source=session_profile.qf_version_source,
|
|
172
|
+
)
|
|
173
|
+
),
|
|
174
|
+
}
|
|
175
|
+
|
|
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"))
|
|
193
|
+
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,
|
|
230
|
+
)
|
|
231
|
+
workspace_qf_version = self._workspace_system_version(workspace)
|
|
232
|
+
if not qf_version_explicit and workspace_qf_version is not None and workspace_qf_version != resolved_qf_version:
|
|
233
|
+
resolved_qf_version = workspace_qf_version
|
|
234
|
+
resolved_qf_version_source = "workspace_system_version"
|
|
235
|
+
session_profile = self.sessions.update_route(
|
|
236
|
+
profile,
|
|
237
|
+
qf_version=resolved_qf_version,
|
|
238
|
+
qf_version_source=resolved_qf_version_source,
|
|
239
|
+
)
|
|
240
|
+
selected_ws_name = workspace.get("workspaceName") or workspace.get("wsName") or workspace.get("remark")
|
|
241
|
+
session_profile = self.sessions.select_workspace(profile, ws_id=ws_id, ws_name=selected_ws_name)
|
|
242
|
+
return {
|
|
243
|
+
"profile": session_profile.profile,
|
|
244
|
+
"base_url": session_profile.base_url,
|
|
245
|
+
"qf_version": session_profile.qf_version,
|
|
246
|
+
"qf_version_source": session_profile.qf_version_source,
|
|
247
|
+
"uid": session_profile.uid,
|
|
248
|
+
"email": session_profile.email,
|
|
249
|
+
"nick_name": session_profile.nick_name,
|
|
250
|
+
"selected_ws_id": session_profile.selected_ws_id,
|
|
251
|
+
"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
|
+
"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
|
+
),
|
|
264
|
+
}
|
|
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")
|
|
307
|
+
|
|
308
|
+
def auth_whoami(self, *, profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
|
|
309
|
+
try:
|
|
310
|
+
session_profile, backend_session, context = self._require_context(profile, require_workspace=False)
|
|
311
|
+
except QingflowApiError as error:
|
|
312
|
+
self._handle_error(profile, error)
|
|
313
|
+
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
|
+
return {
|
|
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
|
+
|
|
337
|
+
def auth_logout(self, *, profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict[str, Any]:
|
|
338
|
+
if not self.sessions.has_profile(profile):
|
|
339
|
+
raise_tool_error(QingflowApiError.auth_required(profile))
|
|
340
|
+
self.sessions.logout(profile, forget_persisted=forget_persisted)
|
|
341
|
+
return {
|
|
342
|
+
"profile": profile,
|
|
343
|
+
"logged_out": True,
|
|
344
|
+
"forgot_persisted": forget_persisted,
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
def _normalize_base_url(self, base_url: str | None) -> str:
|
|
348
|
+
normalized_base_url = normalize_base_url(base_url) or get_default_base_url()
|
|
349
|
+
if not normalized_base_url:
|
|
350
|
+
raise_tool_error(
|
|
351
|
+
QingflowApiError.config_error(
|
|
352
|
+
"base_url is required or configure default_base_url / QINGFLOW_MCP_DEFAULT_BASE_URL"
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
return normalized_base_url
|
|
356
|
+
|
|
357
|
+
def _normalize_qf_version(self, qf_version: str | None) -> str | None:
|
|
358
|
+
if qf_version is not None:
|
|
359
|
+
normalized = str(qf_version).strip()
|
|
360
|
+
return normalized or None
|
|
361
|
+
return get_default_qf_version()
|
|
362
|
+
|
|
363
|
+
def _resolve_qf_version_input(self, qf_version: str | None) -> tuple[str | None, str]:
|
|
364
|
+
if qf_version is not None:
|
|
365
|
+
normalized = self._normalize_qf_version(qf_version)
|
|
366
|
+
return normalized, "explicit" if normalized else "unset"
|
|
367
|
+
normalized = self._normalize_qf_version(None)
|
|
368
|
+
if normalized:
|
|
369
|
+
return normalized, "default_config"
|
|
370
|
+
return None, "unset"
|
|
371
|
+
|
|
372
|
+
def _should_allow_provisional_token_session(self, error: QingflowApiError) -> bool:
|
|
373
|
+
if error.looks_like_invalid_token():
|
|
374
|
+
return False
|
|
375
|
+
return error.http_status == 404 and error.category in {"http", "auth", "workspace"}
|
|
376
|
+
|
|
377
|
+
def _resolve_backend_qf_version(
|
|
378
|
+
self,
|
|
379
|
+
backend_qf_version: str | None,
|
|
380
|
+
*,
|
|
381
|
+
fallback_qf_version: str | None,
|
|
382
|
+
fallback_source: str,
|
|
383
|
+
) -> tuple[str | None, str]:
|
|
384
|
+
if backend_qf_version:
|
|
385
|
+
return backend_qf_version, "backend_response"
|
|
386
|
+
return fallback_qf_version, fallback_source
|
|
387
|
+
|
|
388
|
+
def _workspace_system_version(self, workspace: Any) -> str | None:
|
|
389
|
+
if not isinstance(workspace, dict):
|
|
390
|
+
return None
|
|
391
|
+
value = workspace.get("systemVersion")
|
|
392
|
+
if value is None:
|
|
393
|
+
return None
|
|
394
|
+
normalized = str(value).strip()
|
|
395
|
+
return normalized or None
|
|
396
|
+
|
|
397
|
+
def _fetch_user_info(
|
|
398
|
+
self,
|
|
399
|
+
base_url: str,
|
|
400
|
+
token: str,
|
|
401
|
+
ws_id: int | None,
|
|
402
|
+
*,
|
|
403
|
+
qf_version: str | None,
|
|
404
|
+
qf_version_source: str | None,
|
|
405
|
+
) -> tuple[dict[str, Any], str | None]:
|
|
406
|
+
request_context = BackendRequestContext(
|
|
407
|
+
base_url=base_url,
|
|
408
|
+
token=token,
|
|
409
|
+
ws_id=ws_id,
|
|
410
|
+
qf_version=qf_version,
|
|
411
|
+
qf_version_source=qf_version_source,
|
|
412
|
+
)
|
|
413
|
+
try:
|
|
414
|
+
user_response = self.backend.request_with_meta("GET", request_context, "/user")
|
|
415
|
+
user_info = user_response.data
|
|
416
|
+
if isinstance(user_info, dict):
|
|
417
|
+
return user_info, user_response.qf_response_version
|
|
418
|
+
except QingflowApiError as original_error:
|
|
419
|
+
if ws_id is not None:
|
|
420
|
+
raise original_error
|
|
421
|
+
first_workspace, workspace_qf_version = self._fetch_first_workspace(
|
|
422
|
+
base_url,
|
|
423
|
+
token,
|
|
424
|
+
qf_version=qf_version,
|
|
425
|
+
qf_version_source=qf_version_source,
|
|
426
|
+
)
|
|
427
|
+
if not first_workspace:
|
|
428
|
+
raise original_error
|
|
429
|
+
first_ws_id = first_workspace.get("wsId")
|
|
430
|
+
if not first_ws_id:
|
|
431
|
+
raise original_error
|
|
432
|
+
effective_qf_version = workspace_qf_version or qf_version
|
|
433
|
+
effective_qf_version_source = "backend_response" if workspace_qf_version else qf_version_source
|
|
434
|
+
fallback_context = BackendRequestContext(
|
|
435
|
+
base_url=base_url,
|
|
436
|
+
token=token,
|
|
437
|
+
ws_id=int(first_ws_id),
|
|
438
|
+
qf_version=effective_qf_version,
|
|
439
|
+
qf_version_source=effective_qf_version_source,
|
|
440
|
+
)
|
|
441
|
+
user_response = self.backend.request_with_meta("GET", fallback_context, "/user")
|
|
442
|
+
user_info = user_response.data
|
|
443
|
+
if isinstance(user_info, dict):
|
|
444
|
+
return user_info, user_response.qf_response_version or effective_qf_version
|
|
445
|
+
raise original_error
|
|
446
|
+
raise_tool_error(QingflowApiError(category="auth", message="Token validation did not return valid user info"))
|
|
447
|
+
|
|
448
|
+
def _try_fetch_user_info(
|
|
449
|
+
self,
|
|
450
|
+
base_url: str,
|
|
451
|
+
token: str,
|
|
452
|
+
*,
|
|
453
|
+
qf_version: str | None,
|
|
454
|
+
qf_version_source: str | None,
|
|
455
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
456
|
+
try:
|
|
457
|
+
return self._fetch_user_info(
|
|
458
|
+
base_url,
|
|
459
|
+
token,
|
|
460
|
+
None,
|
|
461
|
+
qf_version=qf_version,
|
|
462
|
+
qf_version_source=qf_version_source,
|
|
463
|
+
)
|
|
464
|
+
except QingflowApiError:
|
|
465
|
+
return None, None
|
|
466
|
+
|
|
467
|
+
def _fetch_first_workspace(
|
|
468
|
+
self,
|
|
469
|
+
base_url: str,
|
|
470
|
+
token: str,
|
|
471
|
+
*,
|
|
472
|
+
qf_version: str | None,
|
|
473
|
+
qf_version_source: str | None,
|
|
474
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
475
|
+
page_response = self.backend.request_with_meta(
|
|
476
|
+
"POST",
|
|
477
|
+
BackendRequestContext(
|
|
478
|
+
base_url=base_url,
|
|
479
|
+
token=token,
|
|
480
|
+
ws_id=None,
|
|
481
|
+
qf_version=qf_version,
|
|
482
|
+
qf_version_source=qf_version_source,
|
|
483
|
+
),
|
|
484
|
+
"/user/workspaceList/pageQuery",
|
|
485
|
+
json_body={"pageNum": 1, "pageSize": 1},
|
|
486
|
+
)
|
|
487
|
+
page = page_response.data
|
|
488
|
+
if not isinstance(page, dict):
|
|
489
|
+
return None, page_response.qf_response_version
|
|
490
|
+
workspaces = page.get("list") or []
|
|
491
|
+
if not workspaces:
|
|
492
|
+
return None, page_response.qf_response_version
|
|
493
|
+
first_workspace = workspaces[0]
|
|
494
|
+
return (first_workspace if isinstance(first_workspace, dict) else None), page_response.qf_response_version
|
|
495
|
+
|
|
496
|
+
def _fetch_workspace(
|
|
497
|
+
self,
|
|
498
|
+
base_url: str,
|
|
499
|
+
token: str,
|
|
500
|
+
ws_id: int,
|
|
501
|
+
*,
|
|
502
|
+
qf_version: str | None,
|
|
503
|
+
qf_version_source: str | None,
|
|
504
|
+
) -> dict[str, Any]:
|
|
505
|
+
workspace = self.backend.request(
|
|
506
|
+
"GET",
|
|
507
|
+
BackendRequestContext(
|
|
508
|
+
base_url=base_url,
|
|
509
|
+
token=token,
|
|
510
|
+
ws_id=None,
|
|
511
|
+
qf_version=qf_version,
|
|
512
|
+
qf_version_source=qf_version_source,
|
|
513
|
+
),
|
|
514
|
+
f"/user/workspace/{ws_id}",
|
|
515
|
+
)
|
|
516
|
+
if not isinstance(workspace, dict):
|
|
517
|
+
raise_tool_error(QingflowApiError(category="workspace", message=f"Workspace {ws_id} is not accessible"))
|
|
518
|
+
return workspace
|
|
519
|
+
|
|
520
|
+
def _request_route_payload(self, context: BackendRequestContext) -> dict[str, Any]:
|
|
521
|
+
describe_route = getattr(self.backend, "describe_route", None)
|
|
522
|
+
if callable(describe_route):
|
|
523
|
+
payload = describe_route(context)
|
|
524
|
+
if isinstance(payload, dict):
|
|
525
|
+
return payload
|
|
526
|
+
return {
|
|
527
|
+
"base_url": context.base_url,
|
|
528
|
+
"qf_version": context.qf_version,
|
|
529
|
+
"qf_version_source": context.qf_version_source or ("context" if context.qf_version else "unknown"),
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
def _should_refresh_identity_metadata(self, session_profile) -> bool: # type: ignore[no-untyped-def]
|
|
533
|
+
return (
|
|
534
|
+
session_profile.uid == 0
|
|
535
|
+
or session_profile.email is None
|
|
536
|
+
or session_profile.nick_name is None
|
|
537
|
+
or session_profile.selected_ws_name is None
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
def _refresh_identity_metadata(
|
|
541
|
+
self,
|
|
542
|
+
*,
|
|
543
|
+
profile: str,
|
|
544
|
+
session_profile, # type: ignore[no-untyped-def]
|
|
545
|
+
backend_session, # type: ignore[no-untyped-def]
|
|
546
|
+
context: BackendRequestContext,
|
|
547
|
+
):
|
|
548
|
+
try:
|
|
549
|
+
user_info, _ = self._fetch_user_info(
|
|
550
|
+
session_profile.base_url,
|
|
551
|
+
backend_session.token,
|
|
552
|
+
session_profile.selected_ws_id,
|
|
553
|
+
qf_version=session_profile.qf_version,
|
|
554
|
+
qf_version_source=session_profile.qf_version_source,
|
|
555
|
+
)
|
|
556
|
+
except QingflowApiError:
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
ws_name = session_profile.selected_ws_name
|
|
560
|
+
if session_profile.selected_ws_id is not None:
|
|
561
|
+
workspace = self._fetch_workspace_with_name_fallback(
|
|
562
|
+
session_profile.base_url,
|
|
563
|
+
backend_session.token,
|
|
564
|
+
session_profile.selected_ws_id,
|
|
565
|
+
qf_version=session_profile.qf_version,
|
|
566
|
+
qf_version_source=session_profile.qf_version_source,
|
|
567
|
+
)
|
|
568
|
+
if isinstance(workspace, dict):
|
|
569
|
+
ws_name = (
|
|
570
|
+
str(workspace.get("workspaceName") or workspace.get("wsName") or workspace.get("remark") or "").strip()
|
|
571
|
+
or ws_name
|
|
572
|
+
)
|
|
573
|
+
email = user_info["email"] if "email" in user_info else session_profile.email
|
|
574
|
+
nick_name = (
|
|
575
|
+
user_info.get("nickName")
|
|
576
|
+
or user_info.get("displayName")
|
|
577
|
+
or user_info.get("name")
|
|
578
|
+
or session_profile.nick_name
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
uid = user_info.get("uid")
|
|
582
|
+
refreshed = self.sessions.update_profile_metadata(
|
|
583
|
+
profile,
|
|
584
|
+
uid=int(uid) if uid is not None else session_profile.uid,
|
|
585
|
+
email=email,
|
|
586
|
+
nick_name=nick_name,
|
|
587
|
+
selected_ws_id=session_profile.selected_ws_id,
|
|
588
|
+
selected_ws_name=ws_name,
|
|
589
|
+
)
|
|
590
|
+
return refreshed
|
|
591
|
+
|
|
592
|
+
def _fetch_workspace_with_name_fallback(
|
|
593
|
+
self,
|
|
594
|
+
base_url: str,
|
|
595
|
+
token: str,
|
|
596
|
+
ws_id: int,
|
|
597
|
+
*,
|
|
598
|
+
qf_version: str | None,
|
|
599
|
+
qf_version_source: str | None,
|
|
600
|
+
) -> dict[str, Any] | None:
|
|
601
|
+
try:
|
|
602
|
+
workspace = self._fetch_workspace(
|
|
603
|
+
base_url,
|
|
604
|
+
token,
|
|
605
|
+
ws_id,
|
|
606
|
+
qf_version=qf_version,
|
|
607
|
+
qf_version_source=qf_version_source,
|
|
608
|
+
)
|
|
609
|
+
except QingflowApiError:
|
|
610
|
+
workspace = None
|
|
611
|
+
if isinstance(workspace, dict):
|
|
612
|
+
workspace_name = str(workspace.get("workspaceName") or workspace.get("wsName") or "").strip()
|
|
613
|
+
if workspace_name:
|
|
614
|
+
return workspace
|
|
615
|
+
try:
|
|
616
|
+
fallback = self._fetch_workspace_from_list(
|
|
617
|
+
base_url,
|
|
618
|
+
token,
|
|
619
|
+
ws_id,
|
|
620
|
+
qf_version=qf_version,
|
|
621
|
+
qf_version_source=qf_version_source,
|
|
622
|
+
)
|
|
623
|
+
except QingflowApiError:
|
|
624
|
+
fallback = None
|
|
625
|
+
return fallback or workspace
|
|
626
|
+
|
|
627
|
+
def _fetch_workspace_from_list(
|
|
628
|
+
self,
|
|
629
|
+
base_url: str,
|
|
630
|
+
token: str,
|
|
631
|
+
ws_id: int,
|
|
632
|
+
*,
|
|
633
|
+
qf_version: str | None,
|
|
634
|
+
qf_version_source: str | None,
|
|
635
|
+
) -> dict[str, Any] | None:
|
|
636
|
+
payload = self.backend.request(
|
|
637
|
+
"POST",
|
|
638
|
+
BackendRequestContext(
|
|
639
|
+
base_url=base_url,
|
|
640
|
+
token=token,
|
|
641
|
+
ws_id=ws_id,
|
|
642
|
+
qf_version=qf_version,
|
|
643
|
+
qf_version_source=qf_version_source,
|
|
644
|
+
),
|
|
645
|
+
"/user/workspaceList/pageQuery",
|
|
646
|
+
json_body={"pageNum": 1, "pageSize": 100, "authList": [0, 1, 2]},
|
|
647
|
+
)
|
|
648
|
+
workspaces = payload.get("list") if isinstance(payload, dict) else []
|
|
649
|
+
if not isinstance(workspaces, list):
|
|
650
|
+
return None
|
|
651
|
+
found = next(
|
|
652
|
+
(
|
|
653
|
+
item
|
|
654
|
+
for item in workspaces
|
|
655
|
+
if isinstance(item, dict) and item.get("wsId") == ws_id
|
|
656
|
+
),
|
|
657
|
+
None,
|
|
658
|
+
)
|
|
659
|
+
return found if isinstance(found, dict) else None
|
|
660
|
+
|
|
661
|
+
def _fetch_public_key(self, base_url: str, *, qf_version: str | None) -> str | None:
|
|
662
|
+
# Endpoints to try (order matters, lowercase 'pubkey' is for Public Cloud)
|
|
663
|
+
endpoints = ["/user/pubkey", "/api/user/pubkey", "/user/publicKey", "/api/user/publicKey"]
|
|
664
|
+
for endpoint in endpoints:
|
|
665
|
+
try:
|
|
666
|
+
# We use unwrap=False to handle various response formats
|
|
667
|
+
result = self.backend.public_request("GET", base_url, endpoint, unwrap=False, qf_version=qf_version)
|
|
668
|
+
if isinstance(result, dict):
|
|
669
|
+
# Try various common response structures
|
|
670
|
+
data = result.get("data") or result.get("result") or result
|
|
671
|
+
if isinstance(data, dict):
|
|
672
|
+
# Try case-insensitive keys
|
|
673
|
+
for key in ["pubkey", "publicKey", "pubKey"]:
|
|
674
|
+
if key in data:
|
|
675
|
+
return str(data[key])
|
|
676
|
+
if isinstance(data, str) and not data.startswith("{"):
|
|
677
|
+
return data
|
|
678
|
+
except Exception:
|
|
679
|
+
continue
|
|
680
|
+
|
|
681
|
+
return None
|
|
682
|
+
|
|
683
|
+
def _encrypt_password(self, password: str, public_key_str: str) -> str:
|
|
684
|
+
try:
|
|
685
|
+
if not public_key_str.startswith("-----BEGIN"):
|
|
686
|
+
key_content = public_key_str.strip()
|
|
687
|
+
formatted_key = f"-----BEGIN PUBLIC KEY-----\n{key_content}\n-----END PUBLIC KEY-----"
|
|
688
|
+
else:
|
|
689
|
+
formatted_key = public_key_str
|
|
690
|
+
|
|
691
|
+
key = RSA.import_key(formatted_key)
|
|
692
|
+
cipher = PKCS1_v1_5.new(key)
|
|
693
|
+
encrypted = cipher.encrypt(password.encode("utf-8"))
|
|
694
|
+
return base64.b64encode(encrypted).decode("utf-8")
|
|
695
|
+
except Exception as e:
|
|
696
|
+
# If encryption fails, fallback to plain text (might be a legacy or custom environment)
|
|
697
|
+
return password
|