@qingflow-tech/qingflow-app-user-mcp 1.0.0

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 (109) hide show
  1. package/README.md +37 -0
  2. package/docs/local-agent-install.md +332 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow-app-user-mcp.mjs +7 -0
  5. package/npm/lib/runtime.mjs +339 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow-app-user-mcp +15 -0
  10. package/skills/qingflow-app-user/SKILL.md +79 -0
  11. package/skills/qingflow-app-user/agents/openai.yaml +4 -0
  12. package/skills/qingflow-app-user/references/data-gotchas.md +29 -0
  13. package/skills/qingflow-app-user/references/environments.md +63 -0
  14. package/skills/qingflow-app-user/references/record-patterns.md +48 -0
  15. package/skills/qingflow-app-user/references/workflow-usage.md +26 -0
  16. package/skills/qingflow-record-analysis/SKILL.md +158 -0
  17. package/skills/qingflow-record-analysis/agents/openai.yaml +4 -0
  18. package/skills/qingflow-record-analysis/references/analysis-gotchas.md +145 -0
  19. package/skills/qingflow-record-analysis/references/analysis-patterns.md +125 -0
  20. package/skills/qingflow-record-analysis/references/confidence-reporting.md +92 -0
  21. package/skills/qingflow-record-analysis/references/dsl-templates.md +93 -0
  22. package/skills/qingflow-record-delete/SKILL.md +29 -0
  23. package/skills/qingflow-record-import/SKILL.md +31 -0
  24. package/skills/qingflow-record-insert/SKILL.md +58 -0
  25. package/skills/qingflow-record-update/SKILL.md +42 -0
  26. package/skills/qingflow-task-ops/SKILL.md +123 -0
  27. package/skills/qingflow-task-ops/agents/openai.yaml +4 -0
  28. package/skills/qingflow-task-ops/references/environments.md +44 -0
  29. package/skills/qingflow-task-ops/references/workflow-usage.md +27 -0
  30. package/src/qingflow_mcp/__init__.py +5 -0
  31. package/src/qingflow_mcp/__main__.py +5 -0
  32. package/src/qingflow_mcp/backend_client.py +649 -0
  33. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  34. package/src/qingflow_mcp/builder_facade/models.py +1836 -0
  35. package/src/qingflow_mcp/builder_facade/service.py +15044 -0
  36. package/src/qingflow_mcp/cli/__init__.py +1 -0
  37. package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
  38. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  39. package/src/qingflow_mcp/cli/commands/auth.py +44 -0
  40. package/src/qingflow_mcp/cli/commands/builder.py +538 -0
  41. package/src/qingflow_mcp/cli/commands/chart.py +18 -0
  42. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  43. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  44. package/src/qingflow_mcp/cli/commands/portal.py +25 -0
  45. package/src/qingflow_mcp/cli/commands/record.py +331 -0
  46. package/src/qingflow_mcp/cli/commands/repo.py +80 -0
  47. package/src/qingflow_mcp/cli/commands/task.py +89 -0
  48. package/src/qingflow_mcp/cli/commands/view.py +18 -0
  49. package/src/qingflow_mcp/cli/commands/workspace.py +25 -0
  50. package/src/qingflow_mcp/cli/context.py +60 -0
  51. package/src/qingflow_mcp/cli/formatters.py +334 -0
  52. package/src/qingflow_mcp/cli/json_io.py +50 -0
  53. package/src/qingflow_mcp/cli/main.py +178 -0
  54. package/src/qingflow_mcp/config.py +513 -0
  55. package/src/qingflow_mcp/errors.py +66 -0
  56. package/src/qingflow_mcp/import_store.py +121 -0
  57. package/src/qingflow_mcp/json_types.py +18 -0
  58. package/src/qingflow_mcp/list_type_labels.py +76 -0
  59. package/src/qingflow_mcp/public_surface.py +233 -0
  60. package/src/qingflow_mcp/repository_store.py +71 -0
  61. package/src/qingflow_mcp/response_trim.py +470 -0
  62. package/src/qingflow_mcp/server.py +212 -0
  63. package/src/qingflow_mcp/server_app_builder.py +533 -0
  64. package/src/qingflow_mcp/server_app_user.py +362 -0
  65. package/src/qingflow_mcp/session_store.py +302 -0
  66. package/src/qingflow_mcp/solution/__init__.py +6 -0
  67. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  68. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  69. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  70. package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
  71. package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
  72. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  73. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  74. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  75. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  76. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  77. package/src/qingflow_mcp/solution/design_session.py +222 -0
  78. package/src/qingflow_mcp/solution/design_store.py +100 -0
  79. package/src/qingflow_mcp/solution/executor.py +2398 -0
  80. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  81. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  82. package/src/qingflow_mcp/solution/run_store.py +244 -0
  83. package/src/qingflow_mcp/solution/spec_models.py +855 -0
  84. package/src/qingflow_mcp/tools/__init__.py +1 -0
  85. package/src/qingflow_mcp/tools/ai_builder_tools.py +3419 -0
  86. package/src/qingflow_mcp/tools/app_tools.py +925 -0
  87. package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
  88. package/src/qingflow_mcp/tools/auth_tools.py +875 -0
  89. package/src/qingflow_mcp/tools/base.py +388 -0
  90. package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
  91. package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
  92. package/src/qingflow_mcp/tools/directory_tools.py +675 -0
  93. package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
  94. package/src/qingflow_mcp/tools/file_tools.py +409 -0
  95. package/src/qingflow_mcp/tools/import_tools.py +2189 -0
  96. package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
  97. package/src/qingflow_mcp/tools/package_tools.py +326 -0
  98. package/src/qingflow_mcp/tools/portal_tools.py +158 -0
  99. package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
  100. package/src/qingflow_mcp/tools/record_tools.py +14037 -0
  101. package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
  102. package/src/qingflow_mcp/tools/resource_read_tools.py +421 -0
  103. package/src/qingflow_mcp/tools/role_tools.py +112 -0
  104. package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
  105. package/src/qingflow_mcp/tools/task_context_tools.py +2228 -0
  106. package/src/qingflow_mcp/tools/task_tools.py +890 -0
  107. package/src/qingflow_mcp/tools/view_tools.py +335 -0
  108. package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
  109. package/src/qingflow_mcp/tools/workspace_tools.py +125 -0
@@ -0,0 +1,388 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from contextvars import ContextVar
5
+ from decimal import Decimal, InvalidOperation
6
+ from typing import Any, Callable, TypeVar
7
+
8
+ from ..backend_client import BackendRequestContext, BackendClient, BackendResponse
9
+ from ..config import (
10
+ get_credit_balance_base_url,
11
+ get_credit_balance_path,
12
+ get_credit_balance_token_key,
13
+ get_credit_balance_token_value,
14
+ get_credit_balance_ws_id_header_key,
15
+ get_credit_meter_enabled,
16
+ get_credit_usage_amount,
17
+ get_credit_usage_base_url,
18
+ get_credit_usage_path,
19
+ get_credit_usage_token_key,
20
+ get_credit_usage_token_value,
21
+ get_credit_usage_ws_id_header_key,
22
+ )
23
+ from ..errors import QingflowApiError, raise_tool_error
24
+ from ..json_types import JSONObject
25
+ from ..session_store import BackendSession, SessionProfile, SessionStore
26
+
27
+
28
+ T = TypeVar("T")
29
+ _RUN_DEPTH: ContextVar[int] = ContextVar("qingflow_mcp_tool_run_depth", default=0)
30
+
31
+
32
+ def tool_cn_name(name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
33
+ """为 tools 方法声明中文展示名(用于计费 extraInfo 记录)。"""
34
+ normalized = str(name).strip()
35
+
36
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
37
+ setattr(func, "__tool_cn_name__", normalized)
38
+ return func
39
+
40
+ return decorator
41
+
42
+
43
+ class ToolBase:
44
+ def __init__(self, sessions: SessionStore, backend: BackendClient) -> None:
45
+ self.sessions = sessions
46
+ self.backend = backend
47
+
48
+ def _require_context(self, profile: str, *, require_workspace: bool) -> tuple[SessionProfile, BackendSession, BackendRequestContext]:
49
+ session_profile = self.sessions.get_profile(profile)
50
+ if session_profile is None:
51
+ raise QingflowApiError.auth_required(profile)
52
+ backend_session = self.sessions.get_backend_session(profile)
53
+ if backend_session is None:
54
+ raise QingflowApiError.auth_required(profile)
55
+ if require_workspace and session_profile.selected_ws_id is None:
56
+ raise QingflowApiError.workspace_not_selected(profile)
57
+ context = BackendRequestContext(
58
+ base_url=backend_session.base_url,
59
+ token=backend_session.token,
60
+ ws_id=session_profile.selected_ws_id if require_workspace else None,
61
+ qf_version=backend_session.qf_version,
62
+ qf_version_source=backend_session.qf_version_source,
63
+ )
64
+ return session_profile, backend_session, context
65
+
66
+ def _run(self, profile: str, func: Callable[[SessionProfile, BackendRequestContext], T], *, require_workspace: bool = True) -> T:
67
+ depth = _RUN_DEPTH.get()
68
+ depth_token = _RUN_DEPTH.set(depth + 1)
69
+ is_top_level_call = depth == 0
70
+ session_profile: SessionProfile | None = None
71
+ backend_session: BackendSession | None = None
72
+ tool_name = self._resolve_calling_tool_name()
73
+ try:
74
+ session_profile, backend_session, context = self._require_context(profile, require_workspace=require_workspace)
75
+ if is_top_level_call:
76
+ self._charge_tool_call(
77
+ profile=profile,
78
+ tool_name=tool_name,
79
+ session_profile=session_profile,
80
+ context=context,
81
+ )
82
+ return func(session_profile, context)
83
+ except QingflowApiError as error:
84
+ if (
85
+ error.looks_like_invalid_token()
86
+ and session_profile is not None
87
+ and backend_session is not None
88
+ and self._refresh_session_from_credential(
89
+ profile,
90
+ session_profile=session_profile,
91
+ backend_session=backend_session,
92
+ )
93
+ ):
94
+ try:
95
+ refreshed_profile, _, refreshed_context = self._require_context(profile, require_workspace=require_workspace)
96
+ return func(refreshed_profile, refreshed_context)
97
+ except QingflowApiError as refreshed_error:
98
+ self._handle_error(profile, refreshed_error)
99
+ self._handle_error(profile, error)
100
+ finally:
101
+ _RUN_DEPTH.reset(depth_token)
102
+ raise AssertionError("unreachable")
103
+
104
+ def _handle_error(self, profile: str, error: QingflowApiError) -> None:
105
+ if error.looks_like_invalid_token():
106
+ self.sessions.invalidate(profile)
107
+ error = QingflowApiError(
108
+ category="auth",
109
+ message=f"Qingflow session for profile '{profile}' has expired. Run auth_use_credential again.",
110
+ backend_code=error.backend_code,
111
+ request_id=error.request_id,
112
+ http_status=error.http_status,
113
+ )
114
+ raise_tool_error(error)
115
+
116
+ def _refresh_session_from_credential(
117
+ self,
118
+ profile: str,
119
+ *,
120
+ session_profile: SessionProfile,
121
+ backend_session: BackendSession,
122
+ ) -> bool:
123
+ credential = (backend_session.credential or "").strip()
124
+ if not credential:
125
+ return False
126
+ try:
127
+ response = self.backend.public_request_with_meta(
128
+ "POST",
129
+ session_profile.base_url,
130
+ "/mcp/auth/context",
131
+ json_body={"credential": credential},
132
+ qf_version=backend_session.qf_version,
133
+ )
134
+ payload = self._unwrap_context_payload(response.data)
135
+ token = self._normalize_text(payload.get("token"))
136
+ ws_id = self._coerce_positive_int(payload.get("wsId"))
137
+ uid = self._coerce_positive_int(payload.get("uid")) or session_profile.uid
138
+ if not token or ws_id is None:
139
+ return False
140
+ backend_qf_version = self._normalize_text(payload.get("qfVersion")) or response.qf_response_version
141
+ qf_version = backend_qf_version if backend_qf_version is not None else backend_session.qf_version
142
+ qf_version_source = (
143
+ "backend_response"
144
+ if backend_qf_version is not None
145
+ else backend_session.qf_version_source
146
+ )
147
+ base_url = self._normalize_text(payload.get("baseUrl")) or session_profile.base_url
148
+ ws_name = self._normalize_text(payload.get("wsName")) or session_profile.selected_ws_name
149
+ self.sessions.save_session(
150
+ profile=profile,
151
+ base_url=base_url,
152
+ qf_version=qf_version,
153
+ qf_version_source=qf_version_source,
154
+ token=token,
155
+ login_token=None,
156
+ credential=credential,
157
+ uid=uid,
158
+ email=self._normalize_text(payload.get("email")) or session_profile.email,
159
+ nick_name=(
160
+ self._normalize_text(payload.get("nickName"))
161
+ or self._normalize_text(payload.get("displayName"))
162
+ or self._normalize_text(payload.get("name"))
163
+ or session_profile.nick_name
164
+ ),
165
+ persist=session_profile.persisted,
166
+ )
167
+ self.sessions.select_workspace(profile, ws_id=ws_id, ws_name=ws_name)
168
+ return True
169
+ except QingflowApiError:
170
+ return False
171
+
172
+ def _unwrap_context_payload(self, payload: Any) -> dict[str, Any]:
173
+ if not isinstance(payload, dict):
174
+ return {}
175
+ nested = payload.get("data")
176
+ if isinstance(nested, dict):
177
+ return nested
178
+ nested = payload.get("result")
179
+ if isinstance(nested, dict):
180
+ return nested
181
+ return payload
182
+
183
+ def _normalize_text(self, value: Any) -> str | None:
184
+ if value is None:
185
+ return None
186
+ text = str(value).strip()
187
+ return text or None
188
+
189
+ def _coerce_positive_int(self, value: Any) -> int | None:
190
+ if isinstance(value, bool) or value is None:
191
+ return None
192
+ try:
193
+ parsed = int(value)
194
+ except (TypeError, ValueError):
195
+ return None
196
+ return parsed if parsed > 0 else None
197
+
198
+ def _resolve_calling_tool_name(self) -> str:
199
+ frame = inspect.currentframe()
200
+ if frame is None:
201
+ return "unknown_tool"
202
+ caller = frame.f_back.f_back if frame.f_back is not None else None
203
+ try:
204
+ if caller is None:
205
+ return "unknown_tool"
206
+ name = caller.f_code.co_name
207
+ if not name:
208
+ return "unknown_tool"
209
+ method = getattr(self, name, None)
210
+ cn_name = getattr(method, "__tool_cn_name__", None)
211
+ if isinstance(cn_name, str) and cn_name.strip():
212
+ return cn_name.strip()
213
+ return name
214
+ finally:
215
+ del frame
216
+
217
+ def _charge_tool_call(
218
+ self,
219
+ *,
220
+ profile: str,
221
+ tool_name: str,
222
+ session_profile: SessionProfile,
223
+ context: BackendRequestContext,
224
+ ) -> None:
225
+ if not get_credit_meter_enabled():
226
+ return
227
+ if not self._credit_meter_is_ready():
228
+ return
229
+ if context.ws_id is None:
230
+ raise_tool_error(QingflowApiError(category="payment", message="credit meter requires wsId in current session context"))
231
+
232
+ usage_amount = self._read_usage_amount()
233
+ available_balance = self._fetch_credit_balance(context.ws_id)
234
+ if available_balance < usage_amount:
235
+ raise_tool_error(
236
+ QingflowApiError(
237
+ category="payment",
238
+ message=f"insufficient credit balance: available={available_balance}, required={usage_amount}",
239
+ )
240
+ )
241
+ self._record_credit_usage(
242
+ tool_name=tool_name,
243
+ ws_id=context.ws_id,
244
+ uid=session_profile.uid,
245
+ )
246
+
247
+ def _credit_meter_is_ready(self) -> bool:
248
+ return bool(
249
+ get_credit_balance_base_url()
250
+ and get_credit_balance_token_value()
251
+ and get_credit_usage_base_url()
252
+ and get_credit_usage_token_value()
253
+ )
254
+
255
+ def _read_usage_amount(self) -> Decimal:
256
+ raw_amount = get_credit_usage_amount()
257
+ try:
258
+ amount = Decimal(str(raw_amount).strip())
259
+ except (InvalidOperation, ValueError):
260
+ raise_tool_error(
261
+ QingflowApiError.config_error(
262
+ "QINGFLOW_MCP_CREDIT_APAAS_AMOUNT must be a positive number"
263
+ )
264
+ )
265
+ if amount <= 0:
266
+ raise_tool_error(
267
+ QingflowApiError.config_error(
268
+ "QINGFLOW_MCP_CREDIT_APAAS_AMOUNT must be a positive number"
269
+ )
270
+ )
271
+ return amount
272
+
273
+ def _fetch_credit_balance(self, ws_id: int) -> Decimal:
274
+ balance_base_url = get_credit_balance_base_url()
275
+ balance_token_key = get_credit_balance_token_key()
276
+ balance_token_value = get_credit_balance_token_value()
277
+ ws_id_header_key = get_credit_balance_ws_id_header_key()
278
+ if not balance_base_url:
279
+ raise_tool_error(
280
+ QingflowApiError.config_error(
281
+ "QINGFLOW_MCP_CREDIT_WINGS_BASE_URL is required when credit meter is enabled"
282
+ )
283
+ )
284
+ if not balance_token_value:
285
+ raise_tool_error(
286
+ QingflowApiError.config_error(
287
+ "QINGFLOW_MCP_CREDIT_WINGS_TOKEN_VALUE is required when credit meter is enabled"
288
+ )
289
+ )
290
+ response = self.backend.public_request_with_headers(
291
+ "GET",
292
+ balance_base_url,
293
+ get_credit_balance_path(),
294
+ headers={
295
+ balance_token_key: balance_token_value,
296
+ ws_id_header_key: str(ws_id),
297
+ },
298
+ )
299
+ payload: Any = response.data if isinstance(response, BackendResponse) else response
300
+ if isinstance(payload, dict):
301
+ daily = self._coerce_decimal(payload.get("dailyBalance"))
302
+ monthly = self._coerce_decimal(payload.get("monthlyBalance"))
303
+ permanent = self._coerce_decimal(payload.get("balance"))
304
+ if daily is not None or monthly is not None or permanent is not None:
305
+ return (daily or Decimal("0")) + (monthly or Decimal("0")) + (permanent or Decimal("0"))
306
+ raise_tool_error(
307
+ QingflowApiError(
308
+ category="payment",
309
+ message="credit balance response is invalid: expected dailyBalance/monthlyBalance/balance",
310
+ )
311
+ )
312
+ raise AssertionError("unreachable")
313
+
314
+ def _record_credit_usage(
315
+ self,
316
+ *,
317
+ tool_name: str,
318
+ ws_id: int,
319
+ uid: int,
320
+ ) -> None:
321
+ usage_base_url = get_credit_usage_base_url()
322
+ usage_token_key = get_credit_usage_token_key()
323
+ usage_token_value = get_credit_usage_token_value()
324
+ ws_id_header_key = get_credit_usage_ws_id_header_key()
325
+ if not usage_base_url:
326
+ raise_tool_error(
327
+ QingflowApiError.config_error(
328
+ "QINGFLOW_MCP_CREDIT_APAAS_BASE_URL is required when credit meter is enabled"
329
+ )
330
+ )
331
+ if not usage_token_value:
332
+ raise_tool_error(
333
+ QingflowApiError.config_error(
334
+ "QINGFLOW_MCP_CREDIT_APAAS_TOKEN_VALUE is required when credit meter is enabled"
335
+ )
336
+ )
337
+ self.backend.public_request_with_headers(
338
+ "POST",
339
+ usage_base_url,
340
+ get_credit_usage_path(),
341
+ headers={
342
+ usage_token_key: usage_token_value,
343
+ ws_id_header_key: str(ws_id),
344
+ },
345
+ json_body={
346
+ "wsId": ws_id,
347
+ "uid": uid,
348
+ "creditUsage": "1",
349
+ "businessType": "WORKSPACE",
350
+ "scene": "MCP",
351
+ "aiBiz": "mcp",
352
+ "extraInfo": tool_name,
353
+ },
354
+ )
355
+
356
+ def _coerce_decimal(self, value: Any) -> Decimal | None:
357
+ if value is None or isinstance(value, bool):
358
+ return None
359
+ try:
360
+ return Decimal(str(value))
361
+ except (InvalidOperation, ValueError):
362
+ return None
363
+
364
+ def _require_dict(self, payload: JSONObject | None, field_name: str = "payload") -> JSONObject:
365
+ if not isinstance(payload, dict) or not payload:
366
+ raise_tool_error(QingflowApiError.config_error(f"{field_name} must be a non-empty object"))
367
+ return payload
368
+
369
+ def _high_risk_tool_description(self, *, operation: str, target: str) -> str:
370
+ return (
371
+ f"High-risk {operation} operation for {target}. Read the current state first, "
372
+ "confirm the exact target IDs and intended diff with a human, and avoid running "
373
+ "against production without explicit approval."
374
+ )
375
+
376
+ def _attach_human_review_notice(self, response: JSONObject, *, operation: str, target: str) -> JSONObject:
377
+ payload = dict(response)
378
+ payload["requires_human_review"] = True
379
+ payload["risk_notice"] = {
380
+ "operation": operation,
381
+ "target": target,
382
+ "severity": "high",
383
+ "guidance": (
384
+ "Read the current state first, confirm the exact target IDs and intended diff with a human, "
385
+ "and require explicit approval before running against production."
386
+ ),
387
+ }
388
+ return payload