@qingflow-tech/qingflow-app-builder-mcp 1.0.2 → 1.0.4

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 (53) hide show
  1. package/README.md +2 -2
  2. package/docs/local-agent-install.md +9 -3
  3. package/npm/lib/runtime.mjs +10 -3
  4. package/package.json +1 -1
  5. package/pyproject.toml +1 -1
  6. package/skills/qingflow-app-builder/SKILL.md +88 -184
  7. package/skills/qingflow-app-builder/references/create-app.md +15 -34
  8. package/skills/qingflow-app-builder/references/gotchas.md +3 -3
  9. package/skills/qingflow-app-builder/references/solution-playbooks.md +1 -2
  10. package/skills/qingflow-app-builder/references/tool-selection.md +9 -10
  11. package/src/qingflow_mcp/__init__.py +33 -1
  12. package/src/qingflow_mcp/backend_client.py +109 -0
  13. package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +58 -9
  15. package/src/qingflow_mcp/builder_facade/service.py +1711 -240
  16. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  17. package/src/qingflow_mcp/cli/commands/app.py +47 -1
  18. package/src/qingflow_mcp/cli/commands/auth.py +63 -0
  19. package/src/qingflow_mcp/cli/commands/builder.py +11 -3
  20. package/src/qingflow_mcp/cli/commands/exports.py +111 -0
  21. package/src/qingflow_mcp/cli/commands/record.py +5 -5
  22. package/src/qingflow_mcp/cli/commands/task.py +701 -27
  23. package/src/qingflow_mcp/cli/commands/workspace.py +84 -0
  24. package/src/qingflow_mcp/cli/context.py +3 -0
  25. package/src/qingflow_mcp/cli/formatters.py +424 -50
  26. package/src/qingflow_mcp/cli/interaction.py +72 -0
  27. package/src/qingflow_mcp/cli/main.py +11 -1
  28. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  29. package/src/qingflow_mcp/cli/terminal_ui.py +218 -0
  30. package/src/qingflow_mcp/config.py +1 -1
  31. package/src/qingflow_mcp/errors.py +4 -4
  32. package/src/qingflow_mcp/export_store.py +14 -0
  33. package/src/qingflow_mcp/id_utils.py +49 -0
  34. package/src/qingflow_mcp/public_surface.py +16 -1
  35. package/src/qingflow_mcp/response_trim.py +394 -9
  36. package/src/qingflow_mcp/server.py +26 -0
  37. package/src/qingflow_mcp/server_app_builder.py +15 -1
  38. package/src/qingflow_mcp/server_app_user.py +113 -0
  39. package/src/qingflow_mcp/session_store.py +126 -21
  40. package/src/qingflow_mcp/solution/compiler/form_compiler.py +2 -2
  41. package/src/qingflow_mcp/solution/executor.py +2 -2
  42. package/src/qingflow_mcp/tools/ai_builder_tools.py +107 -34
  43. package/src/qingflow_mcp/tools/app_tools.py +1 -0
  44. package/src/qingflow_mcp/tools/auth_tools.py +243 -9
  45. package/src/qingflow_mcp/tools/base.py +6 -2
  46. package/src/qingflow_mcp/tools/code_block_tools.py +2 -2
  47. package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
  48. package/src/qingflow_mcp/tools/export_tools.py +1565 -0
  49. package/src/qingflow_mcp/tools/import_tools.py +78 -4
  50. package/src/qingflow_mcp/tools/record_tools.py +551 -165
  51. package/src/qingflow_mcp/tools/resource_read_tools.py +154 -33
  52. package/src/qingflow_mcp/tools/task_context_tools.py +917 -141
  53. package/src/qingflow_mcp/tools/workspace_tools.py +141 -0
@@ -263,6 +263,15 @@ def _trim_workspace_list(payload: JSONObject) -> None:
263
263
  _trim_item_list(page, "list", allowed=("wsId", "workspaceName", "remark"))
264
264
 
265
265
 
266
+ def _trim_workspace_get(payload: JSONObject) -> None:
267
+ workspace = payload.get("workspace")
268
+ if isinstance(workspace, dict):
269
+ payload["workspace"] = _pick(
270
+ workspace,
271
+ allowed=("wsId", "workspaceName", "remark", "systemVersion", "auth"),
272
+ )
273
+
274
+
266
275
  def _trim_app_search_like(payload: JSONObject) -> None:
267
276
  payload.pop("apps", None)
268
277
  _trim_item_list(payload, "items", allowed=("app_key", "app_name", "package_name"))
@@ -287,34 +296,175 @@ def _trim_file_upload_local(payload: JSONObject) -> None:
287
296
 
288
297
 
289
298
  def _trim_import_schema(payload: JSONObject) -> None:
290
- pass
299
+ columns: list[JSONObject] | None = None
300
+ if isinstance(payload.get("columns"), list):
301
+ columns = [item for item in payload.get("columns", []) if isinstance(item, dict)]
302
+ elif isinstance(payload.get("expected_columns"), list):
303
+ columns = [item for item in payload.get("expected_columns", []) if isinstance(item, dict)]
304
+ if columns is not None:
305
+ payload["columns"] = [_compact_import_column(item) for item in columns]
306
+ payload.pop("expected_columns", None)
307
+ payload.pop("schema_fingerprint", None)
308
+ payload.pop("import_capability", None)
309
+ payload.pop("request_route", None)
310
+ payload.pop("verification", None)
311
+
312
+ if _looks_like_import_verify(payload):
313
+ _trim_import_verify_payload(payload)
314
+ return
315
+ if "applied_repairs" in payload or "repaired_file_path" in payload:
316
+ _trim_import_repair_payload(payload)
317
+ return
318
+ if "template_url" in payload or "downloaded_to_path" in payload:
319
+ _trim_import_template_payload(payload)
320
+ return
321
+ if "import_id" in payload or "process_id_str" in payload:
322
+ _trim_import_status_payload(payload)
323
+ return
291
324
 
292
325
 
293
326
  def _trim_record_schema(payload: JSONObject) -> None:
294
327
  payload.pop("legacy_schema", None)
328
+ template_map = payload.get("payload_template")
329
+ if not isinstance(template_map, dict):
330
+ template_map = None
331
+
332
+ if "writable_fields" in payload:
333
+ writable_fields = payload.get("writable_fields")
334
+ payload.pop("writable_fields", None)
335
+ required_fields: list[JSONObject] = []
336
+ optional_fields: list[JSONObject] = []
337
+ if isinstance(writable_fields, list):
338
+ for item in writable_fields:
339
+ compact = _compact_schema_field(item, template_map=template_map)
340
+ if not compact:
341
+ continue
342
+ if compact.get("required") is True:
343
+ required_fields.append(compact)
344
+ else:
345
+ optional_fields.append(compact)
346
+ payload["required_fields"] = required_fields
347
+ payload["optional_fields"] = optional_fields
348
+
349
+ for key in ("required_fields", "optional_fields", "runtime_linked_required_fields", "fields", "ambiguous_fields"):
350
+ if key in payload:
351
+ payload[key] = _compact_schema_fields(payload.get(key), template_map=template_map)
352
+
353
+ for key in ("suggested_dimensions", "suggested_metrics", "suggested_time_fields"):
354
+ if isinstance(payload.get(key), list):
355
+ payload[key] = [
356
+ _pick(item, ("field_id", "title")) for item in payload.get(key) if isinstance(item, dict)
357
+ ]
358
+
359
+ for key in ("workflow_node", "view_resolution", "field_count"):
360
+ payload.pop(key, None)
295
361
 
296
362
 
297
363
  def _trim_record_write(payload: JSONObject) -> None:
364
+ payload.pop("verification", None)
298
365
  data = payload.get("data")
299
366
  if not isinstance(data, dict):
300
367
  return
368
+ data.pop("debug", None)
301
369
  data.pop("normalized_payload", None)
302
370
  data.pop("human_review", None)
303
371
  data.pop("action", None)
372
+ resource = _compact_record_resource(data.get("resource"))
373
+ if resource:
374
+ data["resource"] = resource
375
+ else:
376
+ data.pop("resource", None)
377
+ verification = data.get("verification")
378
+ if isinstance(verification, dict):
379
+ compact_verification = _pick(
380
+ verification,
381
+ (
382
+ "verified",
383
+ "verification_mode",
384
+ "field_level_verified",
385
+ ),
386
+ )
387
+ if compact_verification:
388
+ data["verification"] = compact_verification
389
+ else:
390
+ data.pop("verification", None)
391
+ for key in ("blockers", "field_errors", "confirmation_requests", "resolved_fields"):
392
+ value = data.get(key)
393
+ if value in (None, [], {}, ""):
394
+ data.pop(key, None)
304
395
 
305
396
 
306
397
  def _trim_record_get(payload: JSONObject) -> None:
307
- _keep_nested_keys(payload, ("data", "selection", "view"), allowed=("view_id", "name"))
308
- _drop_nested_keys(payload, ("data", "selection"), keys=("columns", "workflow_node_id"))
398
+ data = payload.get("data")
399
+ if not isinstance(data, dict):
400
+ return
401
+ compact: dict[str, Any] = {}
402
+ app_key = data.get("app_key")
403
+ if app_key:
404
+ compact["app_key"] = app_key
405
+ record_id = data.get("record_id")
406
+ if record_id not in (None, ""):
407
+ compact["record_id"] = str(record_id)
408
+ record = data.get("record")
409
+ if isinstance(record, dict):
410
+ compact["record"] = record
411
+ normalized_record = data.get("normalized_record")
412
+ if isinstance(normalized_record, dict):
413
+ compact["normalized_record"] = normalized_record
414
+ normalized_ambiguous_fields = data.get("normalized_ambiguous_fields")
415
+ if isinstance(normalized_ambiguous_fields, dict):
416
+ compact["normalized_ambiguous_fields"] = normalized_ambiguous_fields
417
+ payload["data"] = compact
309
418
 
310
419
 
311
420
  def _trim_record_list(payload: JSONObject) -> None:
312
- _keep_nested_keys(payload, ("data", "selection", "view"), allowed=("view_id", "name"))
313
- _drop_nested_keys(payload, ("data", "selection"), keys=("columns",))
421
+ data = payload.get("data")
422
+ if not isinstance(data, dict):
423
+ return
424
+ pagination = data.get("pagination") if isinstance(data.get("pagination"), dict) else {}
425
+ returned_items = pagination.get("returned_items")
426
+ result_amount = pagination.get("result_amount")
427
+ limit = pagination.get("limit")
428
+ truncated = False
429
+ if isinstance(result_amount, int) and isinstance(returned_items, int):
430
+ truncated = result_amount > returned_items
431
+ compact_pagination = {
432
+ "loaded": True,
433
+ "page_size": limit,
434
+ "fetched_pages": 1,
435
+ "reported_total": result_amount,
436
+ "truncated": truncated,
437
+ }
438
+ selection = data.get("selection") if isinstance(data.get("selection"), dict) else {}
439
+ view = selection.get("view") if isinstance(selection.get("view"), dict) else {}
440
+ compact: dict[str, Any] = {
441
+ "app_key": data.get("app_key"),
442
+ "items": data.get("items") if isinstance(data.get("items"), list) else [],
443
+ "pagination": compact_pagination,
444
+ }
445
+ if view:
446
+ compact["view"] = _pick(view, ("view_id", "name"))
447
+ payload["data"] = compact
314
448
 
315
449
 
316
450
  def _trim_record_analyze(payload: JSONObject) -> None:
317
- _drop_deep_keys(payload, {"debug"})
451
+ summary: dict[str, Any] = {}
452
+ completeness = payload.get("completeness")
453
+ if isinstance(completeness, dict):
454
+ summary["completeness"] = completeness
455
+ presentation = payload.get("presentation")
456
+ if isinstance(presentation, dict):
457
+ summary["presentation"] = presentation
458
+ ranking = payload.get("ranking")
459
+ if isinstance(ranking, dict):
460
+ summary["ranking"] = ranking
461
+ error = payload.get("error")
462
+ if isinstance(error, dict):
463
+ summary["error"] = error
464
+ if summary:
465
+ payload["summary"] = summary
466
+ for key in ("query", "ranking", "ratios", "completeness", "presentation", "error", "debug"):
467
+ payload.pop(key, None)
318
468
 
319
469
 
320
470
  def _trim_code_block_schema(payload: JSONObject) -> None:
@@ -334,6 +484,229 @@ def _trim_task_get(payload: JSONObject) -> None:
334
484
  _drop_deep_keys(payload, {"request_route", "output_profile"})
335
485
 
336
486
 
487
+ def _trim_task_context_detail(payload: JSONObject) -> None:
488
+ _drop_deep_keys(payload, {"request_route", "output_profile"})
489
+
490
+
491
+ def _trim_record_delete(payload: JSONObject) -> None:
492
+ data = payload.get("data")
493
+ if not isinstance(data, dict):
494
+ return
495
+ resource = data.get("resource")
496
+ deleted_ids: list[str] = []
497
+ if isinstance(resource, dict):
498
+ raw_ids = resource.get("record_ids") or resource.get("apply_ids") or resource.get("applyIds")
499
+ if isinstance(raw_ids, list):
500
+ deleted_ids = [str(item) for item in raw_ids if item not in (None, "")]
501
+ data["deleted_ids"] = deleted_ids
502
+ data.setdefault("failed_ids", [])
503
+ for key in (
504
+ "resource",
505
+ "action",
506
+ "normalized_payload",
507
+ "human_review",
508
+ "verification",
509
+ "blockers",
510
+ "field_errors",
511
+ "confirmation_requests",
512
+ "resolved_fields",
513
+ "debug",
514
+ ):
515
+ data.pop(key, None)
516
+
517
+
518
+ def _compact_record_resource(resource: Any) -> dict[str, Any] | None:
519
+ if not isinstance(resource, dict):
520
+ return None
521
+ compact: dict[str, Any] = {}
522
+ if resource.get("type") not in (None, ""):
523
+ compact["type"] = resource.get("type")
524
+ app_key = resource.get("app_key") or resource.get("appKey")
525
+ if app_key not in (None, ""):
526
+ compact["app_key"] = app_key
527
+ record_id = resource.get("record_id")
528
+ if record_id not in (None, ""):
529
+ compact["record_id"] = str(record_id)
530
+ apply_id = resource.get("apply_id") or resource.get("applyId")
531
+ if apply_id not in (None, "") and "record_id" not in compact:
532
+ compact["record_id"] = str(apply_id)
533
+ record_ids = resource.get("record_ids")
534
+ if isinstance(record_ids, list):
535
+ compact["record_ids"] = [str(item) for item in record_ids if item not in (None, "")]
536
+ apply_ids = resource.get("apply_ids") or resource.get("applyIds")
537
+ if isinstance(apply_ids, list) and "record_ids" not in compact:
538
+ compact["record_ids"] = [str(item) for item in apply_ids if item not in (None, "")]
539
+ return compact or None
540
+
541
+
542
+ def _compact_schema_fields(items: Any, *, template_map: dict[str, Any] | None) -> list[JSONObject]:
543
+ if not isinstance(items, list):
544
+ return []
545
+ compacted: list[JSONObject] = []
546
+ for item in items:
547
+ compact = _compact_schema_field(item, template_map=template_map)
548
+ if compact:
549
+ compacted.append(compact)
550
+ return compacted
551
+
552
+
553
+ def _compact_schema_field(item: Any, *, template_map: dict[str, Any] | None) -> JSONObject | None:
554
+ if not isinstance(item, dict):
555
+ return None
556
+ compact: dict[str, Any] = {}
557
+ field_id = item.get("field_id")
558
+ if field_id not in (None, ""):
559
+ compact["field_id"] = field_id
560
+ title = item.get("title")
561
+ if title not in (None, ""):
562
+ compact["title"] = title
563
+ kind = item.get("kind") or item.get("write_kind")
564
+ if kind not in (None, ""):
565
+ compact["kind"] = kind
566
+ if "required" in item:
567
+ compact["required"] = bool(item.get("required"))
568
+ if template_map is not None and isinstance(title, str) and title in template_map:
569
+ compact["template"] = template_map.get(title)
570
+ candidate_hint = item.get("candidate_hint")
571
+ if isinstance(candidate_hint, dict):
572
+ compact["candidate_hint"] = candidate_hint
573
+ options = item.get("options")
574
+ if isinstance(options, list) and options:
575
+ compact["options"] = options
576
+ target_app_key = item.get("target_app_key")
577
+ if isinstance(target_app_key, str) and target_app_key:
578
+ compact["target_app_key"] = target_app_key
579
+ searchable_fields = item.get("searchable_fields")
580
+ if isinstance(searchable_fields, list) and searchable_fields:
581
+ compact["searchable_fields"] = searchable_fields
582
+ row_fields = item.get("row_fields")
583
+ if isinstance(row_fields, list) and row_fields:
584
+ compact["row_fields"] = _compact_schema_fields(row_fields, template_map=None)
585
+ return compact or None
586
+
587
+
588
+ def _compact_import_column(item: dict[str, Any]) -> dict[str, Any]:
589
+ compact: dict[str, Any] = {}
590
+ title = item.get("title")
591
+ if title not in (None, ""):
592
+ compact["title"] = title
593
+ kind = item.get("kind") or item.get("write_kind")
594
+ if kind not in (None, ""):
595
+ compact["kind"] = kind
596
+ compact["required"] = bool(item.get("required"))
597
+ options = item.get("options")
598
+ if isinstance(options, list) and options:
599
+ compact["options"] = options
600
+ if bool(item.get("accepts_natural_input")):
601
+ compact["accepts_natural_input"] = True
602
+ if bool(item.get("requires_upload")):
603
+ compact["requires_upload"] = True
604
+ target_app_key = item.get("target_app_key")
605
+ if isinstance(target_app_key, str) and target_app_key:
606
+ compact["target_app_key"] = target_app_key
607
+ target_app_name = item.get("target_app_name")
608
+ if isinstance(target_app_name, str) and target_app_name:
609
+ compact["target_app_name"] = target_app_name
610
+ searchable_fields = item.get("searchable_fields")
611
+ if isinstance(searchable_fields, list) and searchable_fields:
612
+ compact["searchable_fields"] = searchable_fields
613
+ return compact
614
+
615
+
616
+ def _looks_like_import_verify(payload: JSONObject) -> bool:
617
+ return "verification_id" in payload and "can_import" in payload
618
+
619
+
620
+ def _trim_import_verify_payload(payload: JSONObject) -> None:
621
+ issues = payload.get("issues") if isinstance(payload.get("issues"), list) else []
622
+ issue_summary = _summarize_import_issues(issues)
623
+ payload["issue_summary"] = issue_summary
624
+ columns = payload.get("columns")
625
+ if "expected_columns" not in payload and isinstance(columns, list):
626
+ payload["expected_columns"] = columns
627
+ file_name = payload.get("file_name")
628
+ if not file_name:
629
+ file_path = payload.get("file_path")
630
+ if isinstance(file_path, str) and file_path:
631
+ payload["file_name"] = file_path.split("/")[-1]
632
+ for key in ("apply_rows", "schema_fingerprint", "import_capability", "file_sha256", "verified_file_sha256", "file_format", "local_precheck_limited"):
633
+ payload.pop(key, None)
634
+
635
+
636
+ def _trim_import_repair_payload(payload: JSONObject) -> None:
637
+ payload["verification_id"] = payload.get("new_verification_id") or payload.get("verification_id")
638
+ post_repair_issues = payload.get("post_repair_issues")
639
+ if isinstance(post_repair_issues, list):
640
+ payload["post_repair_issue_summary"] = _summarize_import_issues(post_repair_issues)
641
+ for key in ("new_verification_id", "verification"):
642
+ payload.pop(key, None)
643
+
644
+
645
+ def _trim_import_template_payload(payload: JSONObject) -> None:
646
+ for key in ("schema_fingerprint", "verification"):
647
+ payload.pop(key, None)
648
+
649
+
650
+ def _trim_import_status_payload(payload: JSONObject) -> None:
651
+ total_rows = payload.get("total_rows")
652
+ success_rows = payload.get("success_rows")
653
+ failed_rows = payload.get("failed_rows")
654
+ payload["total"] = total_rows
655
+ if isinstance(success_rows, int) and isinstance(failed_rows, int):
656
+ payload["finished"] = success_rows + failed_rows
657
+ elif isinstance(success_rows, int):
658
+ payload["finished"] = success_rows
659
+ else:
660
+ payload["finished"] = None
661
+ payload["succeeded"] = success_rows
662
+ payload["failed"] = failed_rows
663
+ for key in (
664
+ "matched_by",
665
+ "source_file_name",
666
+ "total_rows",
667
+ "success_rows",
668
+ "failed_rows",
669
+ "operate_time",
670
+ "operate_user",
671
+ "verification",
672
+ ):
673
+ payload.pop(key, None)
674
+
675
+
676
+ def _trim_export_payload(payload: JSONObject) -> None:
677
+ payload.pop("backend_export_id", None)
678
+
679
+
680
+ def _summarize_import_issues(issues: list[Any]) -> dict[str, Any]:
681
+ total = 0
682
+ error_count = 0
683
+ warning_count = 0
684
+ sample: list[dict[str, Any]] = []
685
+ for item in issues:
686
+ if not isinstance(item, dict):
687
+ continue
688
+ total += 1
689
+ severity = str(item.get("severity") or "").lower()
690
+ if severity == "error":
691
+ error_count += 1
692
+ if severity == "warning":
693
+ warning_count += 1
694
+ if len(sample) < 3:
695
+ sample.append(
696
+ {
697
+ "code": item.get("code"),
698
+ "message": item.get("message"),
699
+ "severity": item.get("severity"),
700
+ }
701
+ )
702
+ return {
703
+ "total": total,
704
+ "errors": error_count,
705
+ "warnings": warning_count,
706
+ "sample": sample,
707
+ }
708
+
709
+
337
710
  def _trim_directory(payload: JSONObject) -> None:
338
711
  pass
339
712
 
@@ -367,6 +740,7 @@ def _register_policy(domains: tuple[str, ...], names: tuple[str, ...], transform
367
740
  _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("auth_use_credential", "auth_whoami"), _trim_auth_payload)
368
741
  _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("auth_logout",), _trim_auth_logout)
369
742
  _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("workspace_list",), _trim_workspace_list)
743
+ _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("workspace_get", "workspace_select"), _trim_workspace_get)
370
744
  _register_policy((USER_DOMAIN,), ("app_list", "app_search"), _trim_app_search_like)
371
745
  _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("app_get",), _trim_app_get)
372
746
  _register_policy((BUILDER_DOMAIN,), ("app_repair_code_blocks",), _trim_builder_list_like)
@@ -385,6 +759,16 @@ _register_policy(
385
759
  ),
386
760
  _trim_import_schema,
387
761
  )
762
+ _register_policy(
763
+ (USER_DOMAIN,),
764
+ (
765
+ "record_export_start",
766
+ "record_export_status_get",
767
+ "record_export_get",
768
+ "record_export_direct",
769
+ ),
770
+ _trim_export_payload,
771
+ )
388
772
  _register_policy(
389
773
  (USER_DOMAIN,),
390
774
  (
@@ -402,15 +786,15 @@ _register_policy((USER_DOMAIN,), ("record_list",), _trim_record_list)
402
786
  _register_policy((USER_DOMAIN,), ("record_analyze",), _trim_record_analyze)
403
787
  _register_policy((USER_DOMAIN,), ("record_code_block_run",), _trim_code_block_run)
404
788
  _register_policy((USER_DOMAIN,), ("task_list",), _trim_task_list)
789
+ _register_policy((USER_DOMAIN,), ("task_get",), _trim_task_get)
405
790
  _register_policy(
406
791
  (USER_DOMAIN,),
407
792
  (
408
- "task_get",
409
793
  "task_action_execute",
410
794
  "task_associated_report_detail_get",
411
795
  "task_workflow_log_get",
412
796
  ),
413
- _trim_task_get,
797
+ _trim_task_context_detail,
414
798
  )
415
799
  _register_policy(
416
800
  (USER_DOMAIN,),
@@ -431,10 +815,10 @@ _register_policy(
431
815
  (
432
816
  "record_member_candidates",
433
817
  "record_department_candidates",
434
- "record_delete",
435
818
  ),
436
819
  _trim_builder_list_like,
437
820
  )
821
+ _register_policy((USER_DOMAIN,), ("record_delete",), _trim_record_delete)
438
822
  _register_policy(
439
823
  (BUILDER_DOMAIN,),
440
824
  (
@@ -447,6 +831,7 @@ _register_policy(
447
831
  "role_create",
448
832
  "app_release_edit_lock_if_mine",
449
833
  "app_resolve",
834
+ "button_style_catalog_get",
450
835
  "app_custom_button_list",
451
836
  "app_custom_button_get",
452
837
  "app_custom_button_create",
@@ -10,6 +10,7 @@ from .tools.app_tools import AppTools
10
10
  from .tools.auth_tools import AuthTools
11
11
  from .tools.code_block_tools import CodeBlockTools
12
12
  from .tools.feedback_tools import FeedbackTools
13
+ from .tools.export_tools import ExportTools
13
14
  from .tools.file_tools import FileTools
14
15
  from .tools.import_tools import ImportTools
15
16
  from .tools.package_tools import PackageTools
@@ -50,6 +51,7 @@ All resource tools operate with the logged-in user's Qingflow permissions.
50
51
  If `app_key` is unknown, use `app_list` or `app_search` first.
51
52
  If the app is known but the data range is not, use `app_get` first and choose from `accessible_views`.
52
53
  If an accessible view has `analysis_supported=false`, do not use it for `record_list` or `record_analyze`. `boardView` and `ganttView` are special UI views, not list/analyze targets.
54
+ `view_get(view_id=...)` also returns `export_capability`; it only means there is a supported export route, not that export permission has been verified.
53
55
 
54
56
  ## Schema-First Rule
55
57
 
@@ -147,10 +149,33 @@ Use `record_code_block_run` when the user wants to execute a form code-block fie
147
149
  - Do not modify user-uploaded files unless the user explicitly authorizes repair.
148
150
  - If repair is authorized, keep the original file and repair a copy, then run `record_import_verify` again before `record_import_start`.
149
151
 
152
+ ## Export Path
153
+
154
+ `view_get -> record_export_start -> record_export_status_get -> record_export_get`
155
+
156
+ - `record_export_direct` is the one-shot export path that starts the export, waits for completion, downloads locally, and still returns remote download links.
157
+ - Export v1 supports record views only and follows the same public `view_id` semantics as `record_list` (`system:*` and `custom:*`).
158
+ - `record_export_start` / `record_export_direct` support frontend-like row selection:
159
+ - omit `record_ids` to export all rows in the selected view
160
+ - pass `record_ids` to export selected rows only
161
+ - `record_export_start` / `record_export_direct` also support internal query selection:
162
+ - pass `where` to resolve matching `record_id` values first
163
+ - pass `order_by` to keep the internal query and export row order aligned with `record_list`
164
+ - then run native export as selected rows
165
+ - `where/order_by` and `record_ids` are mutually exclusive
166
+ - `record_export_start` / `record_export_direct` also support frontend-like column selection:
167
+ - omit `columns` to export all current-view fields
168
+ - pass `columns` to export only selected fields, preserving the provided order
169
+ - `include_workflow_log=true` maps to the native workflow-log export switch.
170
+
150
171
  ## Task Workflow Path
151
172
 
152
173
  `task_list -> task_get -> task_action_execute`
153
174
 
175
+ - `task_list` returns task-card summaries keyed by `task_id`.
176
+ - Prefer `task_get(task_id=...)` for detail reads; MCP resolves the current todo locator internally.
177
+ - `task_action_execute(task_id=..., action=...)` is also supported; MCP resolves the current todo locator internally before calling the real action route.
178
+ - `task_workflow_log_get(task_id=...)` and `task_associated_report_detail_get(task_id=...)` are also supported for the current todo context.
154
179
  - Use `task_associated_report_detail_get` for associated view or report details.
155
180
  - Use `task_workflow_log_get` for full workflow log history.
156
181
  - Task actions operate on `app_key + record_id + workflow_node_id`, not `task_id`.
@@ -186,6 +211,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
186
211
  WorkspaceTools(sessions, backend).register(server)
187
212
  FileTools(sessions, backend).register(server)
188
213
  ImportTools(sessions, backend).register(server)
214
+ ExportTools(sessions, backend).register(server)
189
215
  CodeBlockTools(sessions, backend).register(server)
190
216
  TaskContextTools(sessions, backend).register(server)
191
217
  RoleTools(sessions, backend).register(server)
@@ -38,7 +38,7 @@ def build_builder_server() -> FastMCP:
38
38
  "If creating or updating an app package may be appropriate, use package_apply with explicit user intent; otherwise use package_get and app_resolve to locate resources, "
39
39
  "app_get/app_get_fields/app_repair_code_blocks/app_get_layout/app_get_views/app_get_flow/app_get_charts/portal_list/portal_get/view_get/chart_get for configuration reads, "
40
40
  "member_search/role_search/role_create when workflow assignees must come from the directory or role catalog, preferring roles over explicit members unless the user explicitly names members, "
41
- "then app_schema_apply/app_layout_apply/app_flow_apply/app_views_apply/app_charts_apply/portal_apply to execute normalized patches; these apply tools perform planning, normalization, and dependency checks internally where applicable. Schema/layout/views noop requests skip publish, charts are immediate-live without publish and resolve targets by chart_id first then exact unique chart name, portal updates are replace-only and publish=false only guarantees draft/base-info updates, and flow should use publish=false whenever you only want draft/precheck behavior. "
41
+ "then app_schema_apply/app_layout_apply/app_flow_apply/app_views_apply/app_charts_apply/portal_apply to execute normalized patches; these apply tools perform planning, normalization, and dependency checks internally where applicable. Schema/layout/views noop requests skip publish, charts are immediate-live without publish and resolve targets by chart_id first then exact unique chart name, portal updates use replace semantics only when sections are supplied and edit-mode base-info-only updates may omit sections, publish=false only guarantees draft/base-info updates, and flow should use publish=false whenever you only want draft/precheck behavior. "
42
42
  "For code_block fields with output bindings, always use qf_output assignment rather than const/let qf_output, and use app_repair_code_blocks when an existing form hangs because output-bound fields stay loading. "
43
43
  "Use package_apply to manage package metadata, visibility, grouping, and ordering, and app_publish_verify for explicit final publish verification. "
44
44
  "For workflow edits, keep the public builder surface on stable linear flows only: start/approve/fill/copy/webhook/end. Branch and condition nodes are intentionally disabled because the backend workflow route is not front-end stable for those node types. Declare node assignees and editable fields explicitly. "
@@ -93,6 +93,16 @@ def build_builder_server() -> FastMCP:
93
93
  include_external=include_external,
94
94
  )
95
95
 
96
+ @server.tool()
97
+ def workspace_get(
98
+ profile: str = DEFAULT_PROFILE,
99
+ ws_id: int | None = None,
100
+ ) -> dict:
101
+ return workspace.workspace_get(
102
+ profile=profile,
103
+ ws_id=ws_id,
104
+ )
105
+
96
106
  @server.tool()
97
107
  def file_upload_local(
98
108
  profile: str = DEFAULT_PROFILE,
@@ -280,6 +290,10 @@ def build_builder_server() -> FastMCP:
280
290
  )
281
291
  return ai_builder.app_resolve(profile=profile, app_key=app_key, app_name=app_name, package_id=package_id)
282
292
 
293
+ @server.tool()
294
+ def button_style_catalog_get(profile: str = DEFAULT_PROFILE) -> dict:
295
+ return ai_builder.button_style_catalog_get(profile=profile)
296
+
283
297
  @server.tool()
284
298
  def app_custom_button_list(profile: str = DEFAULT_PROFILE, app_key: str = "") -> dict:
285
299
  return ai_builder.app_custom_button_list(profile=profile, app_key=app_key)