@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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +3 -1
- package/skills/qingflow-app-builder/SKILL.md +154 -22
- package/skills/qingflow-app-builder/references/create-app.md +51 -21
- package/skills/qingflow-app-builder/references/environments.md +1 -1
- package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
- package/skills/qingflow-app-builder/references/gotchas.md +28 -1
- package/skills/qingflow-app-builder/references/solution-playbooks.md +14 -12
- package/skills/qingflow-app-builder/references/tool-selection.md +45 -17
- package/skills/qingflow-app-builder/references/update-flow.md +112 -25
- package/skills/qingflow-app-builder/references/update-layout.md +11 -24
- package/skills/qingflow-app-builder/references/update-schema.md +1 -23
- package/skills/qingflow-app-builder/references/update-views.md +87 -21
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/backend_client.py +189 -0
- package/src/qingflow_mcp/builder_facade/models.py +584 -1
- package/src/qingflow_mcp/builder_facade/service.py +4698 -262
- package/src/qingflow_mcp/config.py +39 -0
- package/src/qingflow_mcp/import_store.py +121 -0
- package/src/qingflow_mcp/list_type_labels.py +24 -0
- package/src/qingflow_mcp/server.py +131 -16
- package/src/qingflow_mcp/server_app_builder.py +132 -72
- package/src/qingflow_mcp/server_app_user.py +143 -187
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +14 -4
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +41 -2
- package/src/qingflow_mcp/solution/executor.py +44 -7
- package/src/qingflow_mcp/tools/ai_builder_tools.py +1567 -144
- package/src/qingflow_mcp/tools/app_tools.py +243 -14
- package/src/qingflow_mcp/tools/approval_tools.py +411 -76
- package/src/qingflow_mcp/tools/directory_tools.py +203 -31
- package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
- package/src/qingflow_mcp/tools/file_tools.py +1 -0
- package/src/qingflow_mcp/tools/import_tools.py +1164 -0
- package/src/qingflow_mcp/tools/portal_tools.py +31 -0
- package/src/qingflow_mcp/tools/record_tools.py +4943 -1025
- package/src/qingflow_mcp/tools/task_context_tools.py +1335 -0
- package/src/qingflow_mcp/tools/task_tools.py +376 -225
- 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 "", "", ""))
|