@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.
Files changed (52) hide show
  1. package/README.md +517 -0
  2. package/docs/local-agent-install.md +213 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow-mcp.mjs +7 -0
  5. package/npm/lib/runtime.mjs +146 -0
  6. package/npm/scripts/postinstall.mjs +12 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +63 -0
  9. package/qingflow-mcp +15 -0
  10. package/src/qingflow_mcp/__init__.py +5 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +336 -0
  13. package/src/qingflow_mcp/config.py +166 -0
  14. package/src/qingflow_mcp/errors.py +66 -0
  15. package/src/qingflow_mcp/json_types.py +18 -0
  16. package/src/qingflow_mcp/server.py +70 -0
  17. package/src/qingflow_mcp/session_store.py +235 -0
  18. package/src/qingflow_mcp/solution/__init__.py +6 -0
  19. package/src/qingflow_mcp/solution/build_assembly_store.py +137 -0
  20. package/src/qingflow_mcp/solution/compiler/__init__.py +265 -0
  21. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  22. package/src/qingflow_mcp/solution/compiler/form_compiler.py +456 -0
  23. package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
  24. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  25. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  26. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  27. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  28. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +134 -0
  29. package/src/qingflow_mcp/solution/design_session.py +222 -0
  30. package/src/qingflow_mcp/solution/design_store.py +100 -0
  31. package/src/qingflow_mcp/solution/executor.py +2064 -0
  32. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  33. package/src/qingflow_mcp/solution/run_store.py +221 -0
  34. package/src/qingflow_mcp/solution/spec_models.py +755 -0
  35. package/src/qingflow_mcp/tools/__init__.py +1 -0
  36. package/src/qingflow_mcp/tools/app_tools.py +239 -0
  37. package/src/qingflow_mcp/tools/approval_tools.py +481 -0
  38. package/src/qingflow_mcp/tools/auth_tools.py +496 -0
  39. package/src/qingflow_mcp/tools/base.py +81 -0
  40. package/src/qingflow_mcp/tools/directory_tools.py +476 -0
  41. package/src/qingflow_mcp/tools/file_tools.py +375 -0
  42. package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
  43. package/src/qingflow_mcp/tools/package_tools.py +142 -0
  44. package/src/qingflow_mcp/tools/portal_tools.py +100 -0
  45. package/src/qingflow_mcp/tools/qingbi_report_tools.py +258 -0
  46. package/src/qingflow_mcp/tools/record_tools.py +4305 -0
  47. package/src/qingflow_mcp/tools/role_tools.py +94 -0
  48. package/src/qingflow_mcp/tools/solution_tools.py +1860 -0
  49. package/src/qingflow_mcp/tools/task_tools.py +677 -0
  50. package/src/qingflow_mcp/tools/view_tools.py +324 -0
  51. package/src/qingflow_mcp/tools/workflow_tools.py +311 -0
  52. 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