@josephyan/qingflow-mcp 0.1.0-beta.2
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 +517 -0
- package/docs/local-agent-install.md +213 -0
- package/entry_point.py +13 -0
- package/npm/bin/qingflow-mcp.mjs +7 -0
- package/npm/lib/runtime.mjs +146 -0
- package/npm/scripts/postinstall.mjs +12 -0
- package/package.json +34 -0
- package/pyproject.toml +63 -0
- package/qingflow-mcp +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 +336 -0
- package/src/qingflow_mcp/config.py +166 -0
- package/src/qingflow_mcp/errors.py +66 -0
- package/src/qingflow_mcp/json_types.py +18 -0
- package/src/qingflow_mcp/server.py +70 -0
- package/src/qingflow_mcp/session_store.py +235 -0
- package/src/qingflow_mcp/solution/__init__.py +6 -0
- package/src/qingflow_mcp/solution/build_assembly_store.py +137 -0
- package/src/qingflow_mcp/solution/compiler/__init__.py +265 -0
- package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +456 -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 +134 -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 +2064 -0
- package/src/qingflow_mcp/solution/normalizer.py +23 -0
- package/src/qingflow_mcp/solution/run_store.py +221 -0
- package/src/qingflow_mcp/solution/spec_models.py +755 -0
- package/src/qingflow_mcp/tools/__init__.py +1 -0
- package/src/qingflow_mcp/tools/app_tools.py +239 -0
- package/src/qingflow_mcp/tools/approval_tools.py +481 -0
- package/src/qingflow_mcp/tools/auth_tools.py +496 -0
- package/src/qingflow_mcp/tools/base.py +81 -0
- package/src/qingflow_mcp/tools/directory_tools.py +476 -0
- package/src/qingflow_mcp/tools/file_tools.py +375 -0
- package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
- package/src/qingflow_mcp/tools/package_tools.py +142 -0
- package/src/qingflow_mcp/tools/portal_tools.py +100 -0
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +258 -0
- package/src/qingflow_mcp/tools/record_tools.py +4305 -0
- package/src/qingflow_mcp/tools/role_tools.py +94 -0
- package/src/qingflow_mcp/tools/solution_tools.py +1860 -0
- package/src/qingflow_mcp/tools/task_tools.py +677 -0
- package/src/qingflow_mcp/tools/view_tools.py +324 -0
- package/src/qingflow_mcp/tools/workflow_tools.py +311 -0
- package/src/qingflow_mcp/tools/workspace_tools.py +143 -0
|
@@ -0,0 +1,496 @@
|
|
|
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
|
+
if not token:
|
|
189
|
+
raise_tool_error(QingflowApiError.config_error("token is required"))
|
|
190
|
+
if ws_id is not None and ws_id <= 0:
|
|
191
|
+
raise_tool_error(QingflowApiError.config_error("ws_id must be positive"))
|
|
192
|
+
try:
|
|
193
|
+
user_info, detected_qf_version = self._fetch_user_info(
|
|
194
|
+
normalized_base_url,
|
|
195
|
+
token,
|
|
196
|
+
ws_id,
|
|
197
|
+
qf_version=normalized_qf_version,
|
|
198
|
+
qf_version_source=qf_version_source,
|
|
199
|
+
)
|
|
200
|
+
resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
|
|
201
|
+
detected_qf_version,
|
|
202
|
+
fallback_qf_version=normalized_qf_version,
|
|
203
|
+
fallback_source=qf_version_source,
|
|
204
|
+
)
|
|
205
|
+
uid = user_info.get("uid")
|
|
206
|
+
if uid is None:
|
|
207
|
+
raise_tool_error(QingflowApiError(category="auth", message="Token validation did not return valid user info"))
|
|
208
|
+
last_ws_info = user_info.get("lastWsInfo") or {}
|
|
209
|
+
session_profile = self.sessions.save_session(
|
|
210
|
+
profile=profile,
|
|
211
|
+
base_url=normalized_base_url,
|
|
212
|
+
qf_version=resolved_qf_version,
|
|
213
|
+
qf_version_source=resolved_qf_version_source,
|
|
214
|
+
token=token,
|
|
215
|
+
login_token=None,
|
|
216
|
+
uid=int(uid),
|
|
217
|
+
email=user_info.get("email"),
|
|
218
|
+
nick_name=user_info.get("nickName") or user_info.get("displayName") or user_info.get("name"),
|
|
219
|
+
persist=persist,
|
|
220
|
+
)
|
|
221
|
+
selected_ws_name = None
|
|
222
|
+
if ws_id is not None:
|
|
223
|
+
workspace = self._fetch_workspace(
|
|
224
|
+
normalized_base_url,
|
|
225
|
+
token,
|
|
226
|
+
ws_id,
|
|
227
|
+
qf_version=resolved_qf_version,
|
|
228
|
+
qf_version_source=resolved_qf_version_source,
|
|
229
|
+
)
|
|
230
|
+
selected_ws_name = workspace.get("workspaceName") or workspace.get("wsName") or workspace.get("remark")
|
|
231
|
+
session_profile = self.sessions.select_workspace(profile, ws_id=ws_id, ws_name=selected_ws_name)
|
|
232
|
+
return {
|
|
233
|
+
"profile": session_profile.profile,
|
|
234
|
+
"base_url": session_profile.base_url,
|
|
235
|
+
"qf_version": session_profile.qf_version,
|
|
236
|
+
"qf_version_source": session_profile.qf_version_source,
|
|
237
|
+
"uid": session_profile.uid,
|
|
238
|
+
"email": session_profile.email,
|
|
239
|
+
"nick_name": session_profile.nick_name,
|
|
240
|
+
"selected_ws_id": session_profile.selected_ws_id,
|
|
241
|
+
"selected_ws_name": session_profile.selected_ws_name,
|
|
242
|
+
"suggested_ws_id": last_ws_info.get("wsId"),
|
|
243
|
+
"suggested_ws_name": last_ws_info.get("wsName") or last_ws_info.get("workspaceName"),
|
|
244
|
+
"persisted": session_profile.persisted,
|
|
245
|
+
"request_route": self._request_route_payload(
|
|
246
|
+
BackendRequestContext(
|
|
247
|
+
base_url=session_profile.base_url,
|
|
248
|
+
token=token,
|
|
249
|
+
ws_id=session_profile.selected_ws_id,
|
|
250
|
+
qf_version=session_profile.qf_version,
|
|
251
|
+
qf_version_source=session_profile.qf_version_source,
|
|
252
|
+
)
|
|
253
|
+
),
|
|
254
|
+
}
|
|
255
|
+
except QingflowApiError as error:
|
|
256
|
+
self._handle_error(profile, error)
|
|
257
|
+
raise AssertionError("unreachable")
|
|
258
|
+
|
|
259
|
+
def auth_whoami(self, *, profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
|
|
260
|
+
try:
|
|
261
|
+
session_profile, _, context = self._require_context(profile, require_workspace=False)
|
|
262
|
+
except QingflowApiError as error:
|
|
263
|
+
self._handle_error(profile, error)
|
|
264
|
+
raise AssertionError("unreachable")
|
|
265
|
+
return {
|
|
266
|
+
"profile": session_profile.profile,
|
|
267
|
+
"base_url": session_profile.base_url,
|
|
268
|
+
"qf_version": session_profile.qf_version,
|
|
269
|
+
"qf_version_source": session_profile.qf_version_source,
|
|
270
|
+
"uid": session_profile.uid,
|
|
271
|
+
"email": session_profile.email,
|
|
272
|
+
"nick_name": session_profile.nick_name,
|
|
273
|
+
"selected_ws_id": session_profile.selected_ws_id,
|
|
274
|
+
"selected_ws_name": session_profile.selected_ws_name,
|
|
275
|
+
"persisted": session_profile.persisted,
|
|
276
|
+
"request_route": self._request_route_payload(context),
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
def auth_logout(self, *, profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict[str, Any]:
|
|
280
|
+
if not self.sessions.has_profile(profile):
|
|
281
|
+
raise_tool_error(QingflowApiError.auth_required(profile))
|
|
282
|
+
self.sessions.logout(profile, forget_persisted=forget_persisted)
|
|
283
|
+
return {
|
|
284
|
+
"profile": profile,
|
|
285
|
+
"logged_out": True,
|
|
286
|
+
"forgot_persisted": forget_persisted,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
def _normalize_base_url(self, base_url: str | None) -> str:
|
|
290
|
+
normalized_base_url = normalize_base_url(base_url) or get_default_base_url()
|
|
291
|
+
if not normalized_base_url:
|
|
292
|
+
raise_tool_error(
|
|
293
|
+
QingflowApiError.config_error(
|
|
294
|
+
"base_url is required or configure default_base_url / QINGFLOW_MCP_DEFAULT_BASE_URL"
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
return normalized_base_url
|
|
298
|
+
|
|
299
|
+
def _normalize_qf_version(self, qf_version: str | None) -> str | None:
|
|
300
|
+
if qf_version is not None:
|
|
301
|
+
normalized = str(qf_version).strip()
|
|
302
|
+
return normalized or None
|
|
303
|
+
return get_default_qf_version()
|
|
304
|
+
|
|
305
|
+
def _resolve_qf_version_input(self, qf_version: str | None) -> tuple[str | None, str]:
|
|
306
|
+
if qf_version is not None:
|
|
307
|
+
normalized = self._normalize_qf_version(qf_version)
|
|
308
|
+
return normalized, "explicit" if normalized else "unset"
|
|
309
|
+
normalized = self._normalize_qf_version(None)
|
|
310
|
+
if normalized:
|
|
311
|
+
return normalized, "default_config"
|
|
312
|
+
return None, "unset"
|
|
313
|
+
|
|
314
|
+
def _resolve_backend_qf_version(
|
|
315
|
+
self,
|
|
316
|
+
backend_qf_version: str | None,
|
|
317
|
+
*,
|
|
318
|
+
fallback_qf_version: str | None,
|
|
319
|
+
fallback_source: str,
|
|
320
|
+
) -> tuple[str | None, str]:
|
|
321
|
+
if backend_qf_version:
|
|
322
|
+
return backend_qf_version, "backend_response"
|
|
323
|
+
return fallback_qf_version, fallback_source
|
|
324
|
+
|
|
325
|
+
def _fetch_user_info(
|
|
326
|
+
self,
|
|
327
|
+
base_url: str,
|
|
328
|
+
token: str,
|
|
329
|
+
ws_id: int | None,
|
|
330
|
+
*,
|
|
331
|
+
qf_version: str | None,
|
|
332
|
+
qf_version_source: str | None,
|
|
333
|
+
) -> tuple[dict[str, Any], str | None]:
|
|
334
|
+
request_context = BackendRequestContext(
|
|
335
|
+
base_url=base_url,
|
|
336
|
+
token=token,
|
|
337
|
+
ws_id=ws_id,
|
|
338
|
+
qf_version=qf_version,
|
|
339
|
+
qf_version_source=qf_version_source,
|
|
340
|
+
)
|
|
341
|
+
try:
|
|
342
|
+
user_response = self.backend.request_with_meta("GET", request_context, "/user")
|
|
343
|
+
user_info = user_response.data
|
|
344
|
+
if isinstance(user_info, dict):
|
|
345
|
+
return user_info, user_response.qf_response_version
|
|
346
|
+
except QingflowApiError as original_error:
|
|
347
|
+
if ws_id is not None:
|
|
348
|
+
raise original_error
|
|
349
|
+
first_workspace, workspace_qf_version = self._fetch_first_workspace(
|
|
350
|
+
base_url,
|
|
351
|
+
token,
|
|
352
|
+
qf_version=qf_version,
|
|
353
|
+
qf_version_source=qf_version_source,
|
|
354
|
+
)
|
|
355
|
+
if not first_workspace:
|
|
356
|
+
raise original_error
|
|
357
|
+
first_ws_id = first_workspace.get("wsId")
|
|
358
|
+
if not first_ws_id:
|
|
359
|
+
raise original_error
|
|
360
|
+
effective_qf_version = workspace_qf_version or qf_version
|
|
361
|
+
effective_qf_version_source = "backend_response" if workspace_qf_version else qf_version_source
|
|
362
|
+
fallback_context = BackendRequestContext(
|
|
363
|
+
base_url=base_url,
|
|
364
|
+
token=token,
|
|
365
|
+
ws_id=int(first_ws_id),
|
|
366
|
+
qf_version=effective_qf_version,
|
|
367
|
+
qf_version_source=effective_qf_version_source,
|
|
368
|
+
)
|
|
369
|
+
user_response = self.backend.request_with_meta("GET", fallback_context, "/user")
|
|
370
|
+
user_info = user_response.data
|
|
371
|
+
if isinstance(user_info, dict):
|
|
372
|
+
return user_info, user_response.qf_response_version or effective_qf_version
|
|
373
|
+
raise original_error
|
|
374
|
+
raise_tool_error(QingflowApiError(category="auth", message="Token validation did not return valid user info"))
|
|
375
|
+
|
|
376
|
+
def _try_fetch_user_info(
|
|
377
|
+
self,
|
|
378
|
+
base_url: str,
|
|
379
|
+
token: str,
|
|
380
|
+
*,
|
|
381
|
+
qf_version: str | None,
|
|
382
|
+
qf_version_source: str | None,
|
|
383
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
384
|
+
try:
|
|
385
|
+
return self._fetch_user_info(
|
|
386
|
+
base_url,
|
|
387
|
+
token,
|
|
388
|
+
None,
|
|
389
|
+
qf_version=qf_version,
|
|
390
|
+
qf_version_source=qf_version_source,
|
|
391
|
+
)
|
|
392
|
+
except QingflowApiError:
|
|
393
|
+
return None, None
|
|
394
|
+
|
|
395
|
+
def _fetch_first_workspace(
|
|
396
|
+
self,
|
|
397
|
+
base_url: str,
|
|
398
|
+
token: str,
|
|
399
|
+
*,
|
|
400
|
+
qf_version: str | None,
|
|
401
|
+
qf_version_source: str | None,
|
|
402
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
403
|
+
page_response = self.backend.request_with_meta(
|
|
404
|
+
"POST",
|
|
405
|
+
BackendRequestContext(
|
|
406
|
+
base_url=base_url,
|
|
407
|
+
token=token,
|
|
408
|
+
ws_id=None,
|
|
409
|
+
qf_version=qf_version,
|
|
410
|
+
qf_version_source=qf_version_source,
|
|
411
|
+
),
|
|
412
|
+
"/user/workspaceList/pageQuery",
|
|
413
|
+
json_body={"pageNum": 1, "pageSize": 1},
|
|
414
|
+
)
|
|
415
|
+
page = page_response.data
|
|
416
|
+
if not isinstance(page, dict):
|
|
417
|
+
return None, page_response.qf_response_version
|
|
418
|
+
workspaces = page.get("list") or []
|
|
419
|
+
if not workspaces:
|
|
420
|
+
return None, page_response.qf_response_version
|
|
421
|
+
first_workspace = workspaces[0]
|
|
422
|
+
return (first_workspace if isinstance(first_workspace, dict) else None), page_response.qf_response_version
|
|
423
|
+
|
|
424
|
+
def _fetch_workspace(
|
|
425
|
+
self,
|
|
426
|
+
base_url: str,
|
|
427
|
+
token: str,
|
|
428
|
+
ws_id: int,
|
|
429
|
+
*,
|
|
430
|
+
qf_version: str | None,
|
|
431
|
+
qf_version_source: str | None,
|
|
432
|
+
) -> dict[str, Any]:
|
|
433
|
+
workspace = self.backend.request(
|
|
434
|
+
"GET",
|
|
435
|
+
BackendRequestContext(
|
|
436
|
+
base_url=base_url,
|
|
437
|
+
token=token,
|
|
438
|
+
ws_id=None,
|
|
439
|
+
qf_version=qf_version,
|
|
440
|
+
qf_version_source=qf_version_source,
|
|
441
|
+
),
|
|
442
|
+
f"/user/workspace/{ws_id}",
|
|
443
|
+
)
|
|
444
|
+
if not isinstance(workspace, dict):
|
|
445
|
+
raise_tool_error(QingflowApiError(category="workspace", message=f"Workspace {ws_id} is not accessible"))
|
|
446
|
+
return workspace
|
|
447
|
+
|
|
448
|
+
def _request_route_payload(self, context: BackendRequestContext) -> dict[str, Any]:
|
|
449
|
+
describe_route = getattr(self.backend, "describe_route", None)
|
|
450
|
+
if callable(describe_route):
|
|
451
|
+
payload = describe_route(context)
|
|
452
|
+
if isinstance(payload, dict):
|
|
453
|
+
return payload
|
|
454
|
+
return {
|
|
455
|
+
"base_url": context.base_url,
|
|
456
|
+
"qf_version": context.qf_version,
|
|
457
|
+
"qf_version_source": context.qf_version_source or ("context" if context.qf_version else "unknown"),
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
def _fetch_public_key(self, base_url: str, *, qf_version: str | None) -> str | None:
|
|
461
|
+
# Endpoints to try (order matters, lowercase 'pubkey' is for Public Cloud)
|
|
462
|
+
endpoints = ["/user/pubkey", "/api/user/pubkey", "/user/publicKey", "/api/user/publicKey"]
|
|
463
|
+
for endpoint in endpoints:
|
|
464
|
+
try:
|
|
465
|
+
# We use unwrap=False to handle various response formats
|
|
466
|
+
result = self.backend.public_request("GET", base_url, endpoint, unwrap=False, qf_version=qf_version)
|
|
467
|
+
if isinstance(result, dict):
|
|
468
|
+
# Try various common response structures
|
|
469
|
+
data = result.get("data") or result.get("result") or result
|
|
470
|
+
if isinstance(data, dict):
|
|
471
|
+
# Try case-insensitive keys
|
|
472
|
+
for key in ["pubkey", "publicKey", "pubKey"]:
|
|
473
|
+
if key in data:
|
|
474
|
+
return str(data[key])
|
|
475
|
+
if isinstance(data, str) and not data.startswith("{"):
|
|
476
|
+
return data
|
|
477
|
+
except Exception:
|
|
478
|
+
continue
|
|
479
|
+
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
def _encrypt_password(self, password: str, public_key_str: str) -> str:
|
|
483
|
+
try:
|
|
484
|
+
if not public_key_str.startswith("-----BEGIN"):
|
|
485
|
+
key_content = public_key_str.strip()
|
|
486
|
+
formatted_key = f"-----BEGIN PUBLIC KEY-----\n{key_content}\n-----END PUBLIC KEY-----"
|
|
487
|
+
else:
|
|
488
|
+
formatted_key = public_key_str
|
|
489
|
+
|
|
490
|
+
key = RSA.import_key(formatted_key)
|
|
491
|
+
cipher = PKCS1_v1_5.new(key)
|
|
492
|
+
encrypted = cipher.encrypt(password.encode("utf-8"))
|
|
493
|
+
return base64.b64encode(encrypted).decode("utf-8")
|
|
494
|
+
except Exception as e:
|
|
495
|
+
# If encryption fails, fallback to plain text (might be a legacy or custom environment)
|
|
496
|
+
return password
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable, TypeVar
|
|
4
|
+
|
|
5
|
+
from ..backend_client import BackendRequestContext, BackendClient
|
|
6
|
+
from ..errors import QingflowApiError, raise_tool_error
|
|
7
|
+
from ..json_types import JSONObject
|
|
8
|
+
from ..session_store import BackendSession, SessionProfile, SessionStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ToolBase:
|
|
15
|
+
def __init__(self, sessions: SessionStore, backend: BackendClient) -> None:
|
|
16
|
+
self.sessions = sessions
|
|
17
|
+
self.backend = backend
|
|
18
|
+
|
|
19
|
+
def _require_context(self, profile: str, *, require_workspace: bool) -> tuple[SessionProfile, BackendSession, BackendRequestContext]:
|
|
20
|
+
session_profile = self.sessions.get_profile(profile)
|
|
21
|
+
if session_profile is None:
|
|
22
|
+
raise QingflowApiError.auth_required(profile)
|
|
23
|
+
backend_session = self.sessions.get_backend_session(profile)
|
|
24
|
+
if backend_session is None:
|
|
25
|
+
raise QingflowApiError.auth_required(profile)
|
|
26
|
+
if require_workspace and session_profile.selected_ws_id is None:
|
|
27
|
+
raise QingflowApiError.workspace_not_selected(profile)
|
|
28
|
+
context = BackendRequestContext(
|
|
29
|
+
base_url=backend_session.base_url,
|
|
30
|
+
token=backend_session.token,
|
|
31
|
+
ws_id=session_profile.selected_ws_id if require_workspace else None,
|
|
32
|
+
qf_version=backend_session.qf_version,
|
|
33
|
+
qf_version_source=backend_session.qf_version_source,
|
|
34
|
+
)
|
|
35
|
+
return session_profile, backend_session, context
|
|
36
|
+
|
|
37
|
+
def _run(self, profile: str, func: Callable[[SessionProfile, BackendRequestContext], T], *, require_workspace: bool = True) -> T:
|
|
38
|
+
try:
|
|
39
|
+
session_profile, _, context = self._require_context(profile, require_workspace=require_workspace)
|
|
40
|
+
return func(session_profile, context)
|
|
41
|
+
except QingflowApiError as error:
|
|
42
|
+
self._handle_error(profile, error)
|
|
43
|
+
raise AssertionError("unreachable")
|
|
44
|
+
|
|
45
|
+
def _handle_error(self, profile: str, error: QingflowApiError) -> None:
|
|
46
|
+
if error.looks_like_invalid_token():
|
|
47
|
+
self.sessions.invalidate(profile)
|
|
48
|
+
error = QingflowApiError(
|
|
49
|
+
category="auth",
|
|
50
|
+
message=f"Qingflow session for profile '{profile}' has expired. Run auth_login again.",
|
|
51
|
+
backend_code=error.backend_code,
|
|
52
|
+
request_id=error.request_id,
|
|
53
|
+
http_status=error.http_status,
|
|
54
|
+
)
|
|
55
|
+
raise_tool_error(error)
|
|
56
|
+
|
|
57
|
+
def _require_dict(self, payload: JSONObject | None, field_name: str = "payload") -> JSONObject:
|
|
58
|
+
if not isinstance(payload, dict) or not payload:
|
|
59
|
+
raise_tool_error(QingflowApiError.config_error(f"{field_name} must be a non-empty object"))
|
|
60
|
+
return payload
|
|
61
|
+
|
|
62
|
+
def _high_risk_tool_description(self, *, operation: str, target: str) -> str:
|
|
63
|
+
return (
|
|
64
|
+
f"High-risk {operation} operation for {target}. Read the current state first, "
|
|
65
|
+
"confirm the exact target IDs and intended diff with a human, and avoid running "
|
|
66
|
+
"against production without explicit approval."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def _attach_human_review_notice(self, response: JSONObject, *, operation: str, target: str) -> JSONObject:
|
|
70
|
+
payload = dict(response)
|
|
71
|
+
payload["requires_human_review"] = True
|
|
72
|
+
payload["risk_notice"] = {
|
|
73
|
+
"operation": operation,
|
|
74
|
+
"target": target,
|
|
75
|
+
"severity": "high",
|
|
76
|
+
"guidance": (
|
|
77
|
+
"Read the current state first, confirm the exact target IDs and intended diff with a human, "
|
|
78
|
+
"and require explicit approval before running against production."
|
|
79
|
+
),
|
|
80
|
+
}
|
|
81
|
+
return payload
|