@josephyan/qingflow-app-builder-mcp 0.2.0-beta.4 → 0.2.0-beta.41

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 (39) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +3 -1
  4. package/skills/qingflow-app-builder/SKILL.md +154 -22
  5. package/skills/qingflow-app-builder/references/create-app.md +51 -21
  6. package/skills/qingflow-app-builder/references/environments.md +1 -1
  7. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
  8. package/skills/qingflow-app-builder/references/gotchas.md +28 -1
  9. package/skills/qingflow-app-builder/references/solution-playbooks.md +14 -12
  10. package/skills/qingflow-app-builder/references/tool-selection.md +45 -17
  11. package/skills/qingflow-app-builder/references/update-flow.md +112 -25
  12. package/skills/qingflow-app-builder/references/update-layout.md +11 -24
  13. package/skills/qingflow-app-builder/references/update-schema.md +1 -23
  14. package/skills/qingflow-app-builder/references/update-views.md +87 -21
  15. package/src/qingflow_mcp/__init__.py +1 -1
  16. package/src/qingflow_mcp/backend_client.py +189 -0
  17. package/src/qingflow_mcp/builder_facade/models.py +584 -1
  18. package/src/qingflow_mcp/builder_facade/service.py +4698 -262
  19. package/src/qingflow_mcp/config.py +39 -0
  20. package/src/qingflow_mcp/import_store.py +121 -0
  21. package/src/qingflow_mcp/list_type_labels.py +24 -0
  22. package/src/qingflow_mcp/server.py +131 -16
  23. package/src/qingflow_mcp/server_app_builder.py +132 -72
  24. package/src/qingflow_mcp/server_app_user.py +143 -187
  25. package/src/qingflow_mcp/solution/compiler/form_compiler.py +14 -4
  26. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +41 -2
  27. package/src/qingflow_mcp/solution/executor.py +44 -7
  28. package/src/qingflow_mcp/tools/ai_builder_tools.py +1567 -144
  29. package/src/qingflow_mcp/tools/app_tools.py +243 -14
  30. package/src/qingflow_mcp/tools/approval_tools.py +411 -76
  31. package/src/qingflow_mcp/tools/directory_tools.py +203 -31
  32. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  33. package/src/qingflow_mcp/tools/file_tools.py +1 -0
  34. package/src/qingflow_mcp/tools/import_tools.py +1164 -0
  35. package/src/qingflow_mcp/tools/portal_tools.py +31 -0
  36. package/src/qingflow_mcp/tools/record_tools.py +4943 -1025
  37. package/src/qingflow_mcp/tools/task_context_tools.py +1335 -0
  38. package/src/qingflow_mcp/tools/task_tools.py +376 -225
  39. package/src/qingflow_mcp/tools/workflow_tools.py +78 -4
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
+ from threading import Event
5
+ from typing import Any
6
+ from urllib.parse import urlsplit, urlunsplit
4
7
  from uuid import uuid4
5
8
 
6
9
  import httpx
@@ -201,6 +204,182 @@ class BackendClient:
201
204
  "body": body,
202
205
  }
203
206
 
207
+ def download_binary(self, url: str, *, headers: dict[str, str] | None = None) -> bytes:
208
+ try:
209
+ response = self._client.get(url, headers=headers or None)
210
+ except httpx.RequestError as exc:
211
+ raise QingflowApiError(category="network", message=str(exc))
212
+ if response.status_code >= 400:
213
+ raise QingflowApiError(
214
+ category="http",
215
+ message=self._extract_message(response.text) or f"HTTP {response.status_code}",
216
+ http_status=response.status_code,
217
+ )
218
+ return response.content
219
+
220
+ def request_multipart(
221
+ self,
222
+ method: str,
223
+ context: BackendRequestContext,
224
+ path: str,
225
+ *,
226
+ data: dict[str, Any] | None = None,
227
+ files: dict[str, tuple[str, bytes, str | None]] | None = None,
228
+ unwrap: bool = True,
229
+ ) -> JSONValue:
230
+ return self.request_multipart_with_meta(
231
+ method,
232
+ context,
233
+ path,
234
+ data=data,
235
+ files=files,
236
+ unwrap=unwrap,
237
+ ).data
238
+
239
+ def request_multipart_with_meta(
240
+ self,
241
+ method: str,
242
+ context: BackendRequestContext,
243
+ path: str,
244
+ *,
245
+ data: dict[str, Any] | None = None,
246
+ files: dict[str, tuple[str, bytes, str | None]] | None = None,
247
+ unwrap: bool = True,
248
+ ) -> BackendResponse:
249
+ headers = self._base_headers(
250
+ context.token,
251
+ context.ws_id,
252
+ context.qf_request_id,
253
+ qf_version=context.qf_version,
254
+ )
255
+ request_files: dict[str, tuple[str, bytes, str | None]] | None = None
256
+ if files:
257
+ request_files = {key: value for key, value in files.items()}
258
+ try:
259
+ response = self._client.request(
260
+ method.upper(),
261
+ self._build_url(context.base_url, path),
262
+ data=data,
263
+ files=request_files,
264
+ headers=headers,
265
+ )
266
+ except httpx.RequestError as exc:
267
+ raise QingflowApiError(category="network", message=str(exc), request_id=headers["Qf-Request-Id"])
268
+ parsed = self._parse_response(response, headers["Qf-Request-Id"], unwrap=unwrap)
269
+ return BackendResponse(
270
+ data=parsed,
271
+ headers=dict(response.headers),
272
+ request_id=headers["Qf-Request-Id"],
273
+ http_status=response.status_code,
274
+ qf_response_version=self._extract_response_qf_version(response.headers),
275
+ )
276
+
277
+ def start_socket_data_import(
278
+ self,
279
+ context: BackendRequestContext,
280
+ *,
281
+ app_key: str,
282
+ being_enter_auditing: bool,
283
+ view_key: str | None,
284
+ excel_url: str,
285
+ excel_name: str,
286
+ ack_timeout_seconds: float = 8.0,
287
+ initial_wait_seconds: float = 4.0,
288
+ ) -> dict[str, Any]:
289
+ try:
290
+ import socketio # type: ignore[import-not-found]
291
+ except ImportError as exc:
292
+ raise QingflowApiError(
293
+ category="config",
294
+ message=f"socket.io client dependency is missing: {exc}",
295
+ )
296
+
297
+ socket_base_url = self._build_socket_base_url(context.base_url)
298
+ import_result: dict[str, Any] = {
299
+ "import_id": None,
300
+ "process_id_str": None,
301
+ "status": "accepted",
302
+ "warnings": [],
303
+ "initial_event": None,
304
+ "failure_event": None,
305
+ }
306
+ initial_event_received = Event()
307
+ failure_event_received = Event()
308
+ sio = socketio.Client(reconnection=False, logger=False, engineio_logger=False)
309
+
310
+ def _handle_initial(payload: Any) -> None:
311
+ if isinstance(payload, dict):
312
+ import_result["initial_event"] = payload
313
+ process_id = payload.get("processIdStr") or payload.get("process_id_str") or payload.get("processId")
314
+ if process_id is not None:
315
+ import_result["process_id_str"] = str(process_id)
316
+ initial_event_received.set()
317
+
318
+ def _handle_failure(payload: Any) -> None:
319
+ if isinstance(payload, dict):
320
+ import_result["failure_event"] = payload
321
+ process_id = payload.get("processIdStr") or payload.get("process_id_str") or payload.get("processId")
322
+ if process_id is not None:
323
+ import_result["process_id_str"] = str(process_id)
324
+ failure_event_received.set()
325
+
326
+ try:
327
+ sio.connect(
328
+ socket_base_url,
329
+ transports=["polling", "websocket"],
330
+ socketio_path="socket.io",
331
+ headers=self._base_headers(
332
+ context.token,
333
+ context.ws_id,
334
+ qf_version=context.qf_version,
335
+ ),
336
+ wait_timeout=ack_timeout_seconds,
337
+ )
338
+ ack = sio.call(
339
+ "dataImport",
340
+ [
341
+ context.token,
342
+ app_key,
343
+ bool(being_enter_auditing),
344
+ view_key,
345
+ False,
346
+ excel_url,
347
+ excel_name,
348
+ ],
349
+ timeout=ack_timeout_seconds,
350
+ )
351
+ import_id = ack[0] if isinstance(ack, list) and ack else ack
352
+ if not import_id:
353
+ raise QingflowApiError(category="backend", message="socket import ack did not return import_id")
354
+ import_result["import_id"] = str(import_id)
355
+ sio.on(f"dataImportRes_{import_result['import_id']}", _handle_initial)
356
+ sio.on(f"dataImportFail_{import_result['import_id']}", _handle_failure)
357
+ if not initial_event_received.wait(timeout=initial_wait_seconds) and not failure_event_received.wait(timeout=0.1):
358
+ import_result["warnings"].append(
359
+ {
360
+ "code": "IMPORT_SOCKET_INITIAL_EVENT_PENDING",
361
+ "message": "Import ack received, but no initial progress payload arrived within the initial wait window.",
362
+ }
363
+ )
364
+ except Exception as exc:
365
+ message = str(exc)
366
+ if "timeout" in message.lower():
367
+ raise QingflowApiError(
368
+ category="network",
369
+ message="socket import ack timed out",
370
+ details={"error_code": "IMPORT_SOCKET_ACK_TIMEOUT"},
371
+ )
372
+ if isinstance(exc, QingflowApiError):
373
+ raise
374
+ raise QingflowApiError(category="network", message=message or "socket import failed")
375
+ finally:
376
+ try:
377
+ if sio.connected:
378
+ sio.disconnect()
379
+ except Exception:
380
+ pass
381
+ return import_result
382
+
204
383
  def _request_with_meta(
205
384
  self,
206
385
  method: str,
@@ -334,3 +513,13 @@ class BackendClient:
334
513
  if not normalized:
335
514
  raise QingflowApiError.config_error("base_url is required")
336
515
  return f"{normalized}/{path.lstrip('/')}"
516
+
517
+ def _build_socket_base_url(self, base_url: str) -> str:
518
+ normalized = normalize_base_url(base_url)
519
+ if not normalized:
520
+ raise QingflowApiError.config_error("base_url is required")
521
+ parsed = urlsplit(normalized)
522
+ path = parsed.path.rstrip("/")
523
+ if path.endswith("/api"):
524
+ path = path[:-4]
525
+ return urlunsplit((parsed.scheme, parsed.netloc, path or "", "", ""))