@josephyan/qingflow-app-user-mcp 0.2.0-beta.1007 → 0.2.0-beta.1010

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 CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.1007
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.1010
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.1007 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.1010 qingflow-app-user-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-user-mcp",
3
- "version": "0.2.0-beta.1007",
3
+ "version": "0.2.0-beta.1010",
4
4
  "description": "Operational end-user MCP for Qingflow records, tasks, comments, and directory workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.2.0b1007"
7
+ version = "0.2.0b1010"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  __all__ = ["__version__"]
7
7
 
8
- _FALLBACK_VERSION = "0.2.0b1007"
8
+ _FALLBACK_VERSION = "0.2.0b1010"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -474,6 +474,115 @@ class BackendClient:
474
474
  pass
475
475
  return import_result
476
476
 
477
+ def start_socket_record_export(
478
+ self,
479
+ context: BackendRequestContext,
480
+ *,
481
+ app_key: str,
482
+ view_id: str,
483
+ filter_bean: JSONObject,
484
+ export_config: JSONObject,
485
+ view_key: str | None = None,
486
+ result_amount: int = 0,
487
+ ack_timeout_seconds: float = 8.0,
488
+ ) -> dict[str, Any]:
489
+ try:
490
+ import socketio # type: ignore[import-not-found]
491
+ except ImportError as exc:
492
+ raise QingflowApiError(
493
+ category="config",
494
+ message=f"socket.io client dependency is missing: {exc}",
495
+ )
496
+
497
+ socket_base_url = self._build_socket_base_url(context.base_url)
498
+ export_result: dict[str, Any] = {
499
+ "backend_export_id": None,
500
+ "warnings": [],
501
+ }
502
+ sio = socketio.Client(reconnection=False, logger=False, engineio_logger=False)
503
+ event_name = "excelViewgraph" if view_key else "excel"
504
+ event_args: tuple[Any, ...]
505
+ if view_key:
506
+ event_args = (
507
+ context.token,
508
+ None,
509
+ view_key,
510
+ filter_bean,
511
+ export_config,
512
+ int(result_amount),
513
+ )
514
+ else:
515
+ event_args = (
516
+ context.token,
517
+ app_key,
518
+ filter_bean,
519
+ export_config,
520
+ int(result_amount),
521
+ )
522
+ try:
523
+ sio.connect(
524
+ socket_base_url,
525
+ transports=["websocket"],
526
+ socketio_path="socket.io",
527
+ headers=self._base_headers(
528
+ context.token,
529
+ context.ws_id,
530
+ qf_version=context.qf_version,
531
+ ),
532
+ wait_timeout=ack_timeout_seconds,
533
+ )
534
+ sio.emit("token", context.token)
535
+ sleep(0.2)
536
+ ack = sio.call(
537
+ event_name,
538
+ event_args,
539
+ timeout=ack_timeout_seconds,
540
+ )
541
+ ack_payload = ack[0] if isinstance(ack, list) and ack else ack
542
+ export_id: Any = ack_payload
543
+ if isinstance(ack_payload, dict):
544
+ error_code = ack_payload.get("error")
545
+ ack_message = ack_payload.get("message")
546
+ export_id = ack_payload.get("data")
547
+ if isinstance(export_id, dict):
548
+ export_id = (
549
+ export_id.get("exportId")
550
+ or export_id.get("export_id")
551
+ or export_id.get("id")
552
+ )
553
+ if error_code not in (None, 0):
554
+ raise QingflowApiError(
555
+ category="backend",
556
+ message=str(ack_message or f"socket export rejected with error {error_code}"),
557
+ details={
558
+ "socket_error_code": error_code,
559
+ "app_key": app_key,
560
+ "view_id": view_id,
561
+ "view_key": view_key,
562
+ },
563
+ )
564
+ if not export_id:
565
+ raise QingflowApiError(category="backend", message="socket export ack did not return export_id")
566
+ export_result["backend_export_id"] = str(export_id)
567
+ except Exception as exc:
568
+ message = str(exc)
569
+ if "timeout" in message.lower():
570
+ raise QingflowApiError(
571
+ category="network",
572
+ message="socket export ack timed out",
573
+ details={"error_code": "EXPORT_SOCKET_ACK_TIMEOUT"},
574
+ )
575
+ if isinstance(exc, QingflowApiError):
576
+ raise
577
+ raise QingflowApiError(category="network", message=message or "socket export failed")
578
+ finally:
579
+ try:
580
+ if sio.connected:
581
+ sio.disconnect()
582
+ except Exception:
583
+ pass
584
+ return export_result
585
+
477
586
  def _request_with_meta(
478
587
  self,
479
588
  method: str,
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
 
5
- from . import app, auth, builder, chart, imports, portal, record, task, view, workspace
5
+ from . import app, auth, builder, chart, exports, imports, portal, record, task, view, workspace
6
6
 
7
7
 
8
8
  def register_all_commands(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
@@ -14,5 +14,6 @@ def register_all_commands(subparsers: argparse._SubParsersAction[argparse.Argume
14
14
  chart.register(subparsers)
15
15
  record.register(subparsers)
16
16
  imports.register(subparsers)
17
+ exports.register(subparsers)
17
18
  task.register(subparsers)
18
19
  builder.register(subparsers)
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from ..context import CliContext
6
+
7
+
8
+ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
9
+ parser = subparsers.add_parser("export", help="导出")
10
+ export_subparsers = parser.add_subparsers(dest="export_command", required=True)
11
+
12
+ start = export_subparsers.add_parser("start", help="启动导出")
13
+ start.add_argument("--app-key", required=True)
14
+ start.add_argument("--view-id", default="system:all")
15
+ start.set_defaults(handler=_handle_start, format_hint="export_start")
16
+
17
+ status = export_subparsers.add_parser("status", help="查询导出状态")
18
+ status.add_argument("--export-handle", required=True)
19
+ status.set_defaults(handler=_handle_status, format_hint="export_status")
20
+
21
+ get = export_subparsers.add_parser("get", help="获取导出结果")
22
+ get.add_argument("--export-handle", required=True)
23
+ get.add_argument("--download-to-path")
24
+ get.set_defaults(handler=_handle_get, format_hint="export_get")
25
+
26
+ direct = export_subparsers.add_parser("direct", help="直接导出并下载")
27
+ direct.add_argument("--app-key", required=True)
28
+ direct.add_argument("--view-id", default="system:all")
29
+ direct.add_argument("--download-to-path")
30
+ direct.add_argument("--wait-timeout-seconds", type=float)
31
+ direct.set_defaults(handler=_handle_direct, format_hint="export_direct")
32
+
33
+
34
+ def _handle_start(args: argparse.Namespace, context: CliContext) -> dict:
35
+ return context.exports.record_export_start(
36
+ profile=args.profile,
37
+ app_key=args.app_key,
38
+ view_id=args.view_id,
39
+ )
40
+
41
+
42
+ def _handle_status(args: argparse.Namespace, context: CliContext) -> dict:
43
+ return context.exports.record_export_status_get(
44
+ profile=args.profile,
45
+ export_handle=args.export_handle,
46
+ )
47
+
48
+
49
+ def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
50
+ return context.exports.record_export_get(
51
+ profile=args.profile,
52
+ export_handle=args.export_handle,
53
+ download_to_path=args.download_to_path,
54
+ )
55
+
56
+
57
+ def _handle_direct(args: argparse.Namespace, context: CliContext) -> dict:
58
+ return context.exports.record_export_direct(
59
+ profile=args.profile,
60
+ app_key=args.app_key,
61
+ view_id=args.view_id,
62
+ download_to_path=args.download_to_path,
63
+ wait_timeout_seconds=args.wait_timeout_seconds,
64
+ )
@@ -212,25 +212,15 @@ def _run_task_workbench_task_loop(args: argparse.Namespace, context: CliContext)
212
212
  detail_selection = _show_task_workbench_detail(args, task_context)
213
213
  if detail_selection.status == "cancelled":
214
214
  return "back", ""
215
- if str(detail_selection.value or "") == "exit":
215
+ selected_value = str(detail_selection.value or "").strip()
216
+ if selected_value == "exit":
216
217
  return "result", cancelled_result("已退出")
217
- if str(detail_selection.value or "") != "action":
218
+ if selected_value == "back":
218
219
  return "back", ""
219
-
220
- action_selection = _choose_task_action_interactively(
221
- args,
222
- task_context,
223
- fields={},
224
- title=_build_task_action_title(task_context),
225
- )
226
- if action_selection.status == "cancelled":
227
- continue
228
- if action_selection.status == "empty":
220
+ if not selected_value:
229
221
  continue
230
- if action_selection.status != "selected":
231
- return "result", cancelled_result("已退出")
232
222
 
233
- args.action = str(action_selection.value or "").strip()
223
+ args.action = selected_value
234
224
  payload_selection = _resolve_action_payload_or_select(args, task_context, payload={})
235
225
  if _is_cancelled_result(payload_selection):
236
226
  continue
@@ -421,35 +411,8 @@ def _choose_task_action_interactively(
421
411
  fields: dict[str, Any],
422
412
  title: str = "选择操作",
423
413
  ):
424
- data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
425
- available_actions = [str(item).strip() for item in (data.get("available_actions") or []) if str(item).strip()]
426
- extras = data.get("extras") if isinstance(data.get("extras"), dict) else {}
427
- action_metadata = data.get("action_metadata") if isinstance(data.get("action_metadata"), dict) else {}
428
- feedback_required = {
429
- str(item).strip()
430
- for item in (action_metadata.get("feedback_required_for") or [])
431
- if str(item).strip()
432
- }
433
- rollback_count = _count_candidate_items(extras.get("rollback_candidates"))
434
- transfer_count = _count_candidate_items(extras.get("transfer_candidates"))
435
-
436
414
  def load_options() -> list[SelectionOption[str]]:
437
- options: list[SelectionOption[str]] = []
438
- for action in available_actions:
439
- if action == "save_only" and not fields:
440
- continue
441
- label = TASK_ACTION_LABELS.get(action, action)
442
- hint_parts = [action]
443
- if action in feedback_required:
444
- hint_parts.append("需要理由")
445
- if action == "rollback":
446
- hint_parts.append(f"可退回节点 {rollback_count}")
447
- if action == "transfer":
448
- hint_parts.append(f"可转交成员 {transfer_count}")
449
- if action == "save_only":
450
- hint_parts.append("仅保存字段,不推进流程")
451
- options.append(SelectionOption(value=action, label=label, hint=" · ".join(hint_parts)))
452
- return options
415
+ return _task_interactive_action_options(task_context, fields=fields)
453
416
 
454
417
  return resolve_interactive_selection(
455
418
  args,
@@ -461,14 +424,11 @@ def _choose_task_action_interactively(
461
424
 
462
425
 
463
426
  def _show_task_workbench_detail(args: argparse.Namespace, task_context: dict[str, Any]):
464
- has_actions = _task_has_interactive_actions(task_context)
465
-
466
427
  def load_options() -> list[SelectionOption[str]]:
467
- if has_actions:
468
- return [
469
- SelectionOption(value="action", label="执行操作", hint="查看当前节点可执行动作"),
470
- SelectionOption(value="back", label="返回列表", hint="回到待办列表"),
471
- ]
428
+ options = _task_interactive_action_options(task_context, fields={})
429
+ if options:
430
+ options.append(SelectionOption(value="back", label="返回列表", hint="回到待办列表"))
431
+ return options
472
432
  return [
473
433
  SelectionOption(value="back", label="返回列表", hint="当前节点没有可执行动作"),
474
434
  SelectionOption(value="exit", label="退出工作台", hint="结束当前待办工作台"),
@@ -697,10 +657,6 @@ def _build_task_detail_title(task_context: dict[str, Any]) -> str:
697
657
  return "\n".join(lines)
698
658
 
699
659
 
700
- def _build_task_action_title(task_context: dict[str, Any]) -> str:
701
- return _build_task_detail_title(task_context) + "\n\n选择操作"
702
-
703
-
704
660
  def _task_initiator_label(initiator: dict[str, Any]) -> str:
705
661
  for key in ("display_name", "displayName", "name", "email", "uid"):
706
662
  value = initiator.get(key)
@@ -709,10 +665,35 @@ def _task_initiator_label(initiator: dict[str, Any]) -> str:
709
665
  return "-"
710
666
 
711
667
 
712
- def _task_has_interactive_actions(task_context: dict[str, Any]) -> bool:
668
+ def _task_interactive_action_options(task_context: dict[str, Any], *, fields: dict[str, Any]) -> list[SelectionOption[str]]:
713
669
  data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
714
670
  available_actions = [str(item).strip() for item in (data.get("available_actions") or []) if str(item).strip()]
715
- return any(action != "save_only" for action in available_actions)
671
+ action_metadata = data.get("action_metadata") if isinstance(data.get("action_metadata"), dict) else {}
672
+ extras = data.get("extras") if isinstance(data.get("extras"), dict) else {}
673
+ feedback_required = {
674
+ str(item).strip()
675
+ for item in (action_metadata.get("feedback_required_for") or [])
676
+ if str(item).strip()
677
+ }
678
+ rollback_count = _count_candidate_items(extras.get("rollback_candidates"))
679
+ transfer_count = _count_candidate_items(extras.get("transfer_candidates"))
680
+
681
+ options: list[SelectionOption[str]] = []
682
+ for action in available_actions:
683
+ if action == "save_only" and not fields:
684
+ continue
685
+ label = TASK_ACTION_LABELS.get(action, action)
686
+ hint_parts = [action]
687
+ if action in feedback_required:
688
+ hint_parts.append("需要理由")
689
+ if action == "rollback":
690
+ hint_parts.append(f"可退回节点 {rollback_count}")
691
+ if action == "transfer":
692
+ hint_parts.append(f"可转交成员 {transfer_count}")
693
+ if action == "save_only":
694
+ hint_parts.append("仅保存字段,不推进流程")
695
+ options.append(SelectionOption(value=action, label=label, hint=" · ".join(hint_parts)))
696
+ return options
716
697
 
717
698
 
718
699
  def _full_display(value: Any) -> str:
@@ -8,6 +8,7 @@ from ..tools.ai_builder_tools import AiBuilderTools
8
8
  from ..tools.app_tools import AppTools
9
9
  from ..tools.auth_tools import AuthTools
10
10
  from ..tools.code_block_tools import CodeBlockTools
11
+ from ..tools.export_tools import ExportTools
11
12
  from ..tools.feedback_tools import FeedbackTools
12
13
  from ..tools.file_tools import FileTools
13
14
  from ..tools.import_tools import ImportTools
@@ -29,6 +30,7 @@ class CliContext:
29
30
  record: RecordTools
30
31
  code_block: CodeBlockTools
31
32
  imports: ImportTools
33
+ exports: ExportTools
32
34
  task: TaskContextTools
33
35
  files: FileTools
34
36
  builder_feedback: FeedbackTools
@@ -52,6 +54,7 @@ def build_cli_context() -> CliContext:
52
54
  record=RecordTools(sessions, backend),
53
55
  code_block=CodeBlockTools(sessions, backend),
54
56
  imports=ImportTools(sessions, backend),
57
+ exports=ExportTools(sessions, backend),
55
58
  task=TaskContextTools(sessions, backend),
56
59
  files=FileTools(sessions, backend),
57
60
  builder_feedback=FeedbackTools(backend, mcp_side="App Builder MCP"),
@@ -412,6 +412,58 @@ def _format_import_status(result: dict[str, Any]) -> str:
412
412
  return "\n".join(lines) + "\n"
413
413
 
414
414
 
415
+ def _format_export_common(result: dict[str, Any], *, title: str | None = None) -> str:
416
+ lines: list[str] = []
417
+ if title:
418
+ lines.append(title)
419
+ lines.extend(
420
+ [
421
+ f"Status: {result.get('status') or '-'}",
422
+ f"Export Handle: {result.get('export_handle') or '-'}",
423
+ f"App Key: {result.get('app_key') or '-'}",
424
+ f"View ID: {result.get('view_id') or '-'}",
425
+ f"Process Status: {result.get('process_status') or '-'}",
426
+ f"Rows: {result.get('num') if result.get('num') is not None else '-'}",
427
+ ]
428
+ )
429
+ file_names = result.get("file_names") if isinstance(result.get("file_names"), list) else []
430
+ file_urls = result.get("file_urls") if isinstance(result.get("file_urls"), list) else []
431
+ if file_names or file_urls:
432
+ lines.append("Remote Files:")
433
+ max_items = max(len(file_names), len(file_urls))
434
+ for index in range(max_items):
435
+ name = file_names[index] if index < len(file_names) else "-"
436
+ url = file_urls[index] if index < len(file_urls) else "-"
437
+ lines.append(f"- {name}: {url}")
438
+ downloaded_files = result.get("downloaded_files") if isinstance(result.get("downloaded_files"), list) else []
439
+ if downloaded_files:
440
+ lines.append("Downloaded Files:")
441
+ for item in downloaded_files:
442
+ if isinstance(item, dict):
443
+ lines.append(f"- {item.get('file_name') or '-'}: {item.get('path') or '-'}")
444
+ else:
445
+ lines.append(f"- {item}")
446
+ _append_warnings(lines, result.get("warnings"))
447
+ _append_verification(lines, result.get("verification"))
448
+ return "\n".join(lines) + "\n"
449
+
450
+
451
+ def _format_export_start(result: dict[str, Any]) -> str:
452
+ return _format_export_common(result, title="Export Accepted")
453
+
454
+
455
+ def _format_export_status(result: dict[str, Any]) -> str:
456
+ return _format_export_common(result, title="Export Status")
457
+
458
+
459
+ def _format_export_get(result: dict[str, Any]) -> str:
460
+ return _format_export_common(result, title="Export Result")
461
+
462
+
463
+ def _format_export_direct(result: dict[str, Any]) -> str:
464
+ return _format_export_common(result, title="Export Direct")
465
+
466
+
415
467
  def _format_builder_summary(result: dict[str, Any]) -> str:
416
468
  lines = []
417
469
  if "status" in result:
@@ -626,5 +678,9 @@ _FORMATTERS = {
626
678
  "task_associated_report_detail_get": _format_task_associated_report_detail,
627
679
  "import_verify": _format_import_verify,
628
680
  "import_status": _format_import_status,
681
+ "export_start": _format_export_start,
682
+ "export_status": _format_export_status,
683
+ "export_get": _format_export_get,
684
+ "export_direct": _format_export_direct,
629
685
  "builder_summary": _format_builder_summary,
630
686
  }
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import timedelta
4
+ from pathlib import Path
5
+
6
+ from .import_store import _JsonEntryStore, _store_dir
7
+
8
+
9
+ class ExportJobStore(_JsonEntryStore):
10
+ def __init__(self, base_dir: Path | None = None, *, ttl_seconds: int = 24 * 3600) -> None:
11
+ super().__init__(
12
+ base_dir=base_dir or _store_dir("QINGFLOW_MCP_EXPORT_JOB_HOME", "export-jobs"),
13
+ ttl=timedelta(seconds=ttl_seconds),
14
+ )
@@ -91,6 +91,10 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
91
91
  PublicToolSpec(USER_DOMAIN, "record_import_repair_local", ("record_import_repair_local",), ("import", "repair")),
92
92
  PublicToolSpec(USER_DOMAIN, "record_import_start", ("record_import_start",), ("import", "start")),
93
93
  PublicToolSpec(USER_DOMAIN, "record_import_status_get", ("record_import_status_get",), ("import", "status")),
94
+ PublicToolSpec(USER_DOMAIN, "record_export_start", ("record_export_start",), ("export", "start"), cli_show_effective_context=True),
95
+ PublicToolSpec(USER_DOMAIN, "record_export_status_get", ("record_export_status_get",), ("export", "status"), cli_show_effective_context=True),
96
+ PublicToolSpec(USER_DOMAIN, "record_export_get", ("record_export_get",), ("export", "get"), cli_show_effective_context=True),
97
+ PublicToolSpec(USER_DOMAIN, "record_export_direct", ("record_export_direct",), ("export", "direct"), cli_show_effective_context=True),
94
98
  PublicToolSpec(USER_DOMAIN, "record_code_block_run", ("record_code_block_run",), ("record", "code-block-run"), cli_show_effective_context=True, cli_context_write=True),
95
99
  PublicToolSpec(USER_DOMAIN, "task_list", ("task_list",), ("task", "list"), cli_show_effective_context=True),
96
100
  PublicToolSpec(USER_DOMAIN, "task_get", ("task_get",), ("task", "get"), cli_show_effective_context=True),
@@ -674,6 +674,10 @@ def _trim_import_status_payload(payload: JSONObject) -> None:
674
674
  payload.pop(key, None)
675
675
 
676
676
 
677
+ def _trim_export_payload(payload: JSONObject) -> None:
678
+ payload.pop("backend_export_id", None)
679
+
680
+
677
681
  def _summarize_import_issues(issues: list[Any]) -> dict[str, Any]:
678
682
  total = 0
679
683
  error_count = 0
@@ -756,6 +760,16 @@ _register_policy(
756
760
  ),
757
761
  _trim_import_schema,
758
762
  )
763
+ _register_policy(
764
+ (USER_DOMAIN,),
765
+ (
766
+ "record_export_start",
767
+ "record_export_status_get",
768
+ "record_export_get",
769
+ "record_export_direct",
770
+ ),
771
+ _trim_export_payload,
772
+ )
759
773
  _register_policy(
760
774
  (USER_DOMAIN,),
761
775
  (
@@ -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,6 +149,13 @@ 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
+
150
159
  ## Task Workflow Path
151
160
 
152
161
  `task_list -> task_get -> task_action_execute`
@@ -190,6 +199,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
190
199
  WorkspaceTools(sessions, backend).register(server)
191
200
  FileTools(sessions, backend).register(server)
192
201
  ImportTools(sessions, backend).register(server)
202
+ ExportTools(sessions, backend).register(server)
193
203
  CodeBlockTools(sessions, backend).register(server)
194
204
  TaskContextTools(sessions, backend).register(server)
195
205
  RoleTools(sessions, backend).register(server)
@@ -13,6 +13,7 @@ from .tools.app_tools import AppTools
13
13
  from .tools.auth_tools import AuthTools
14
14
  from .tools.code_block_tools import CodeBlockTools
15
15
  from .tools.directory_tools import DirectoryTools
16
+ from .tools.export_tools import ExportTools
16
17
  from .tools.feedback_tools import FeedbackTools
17
18
  from .tools.file_tools import FileTools
18
19
  from .tools.import_tools import ImportTools
@@ -33,6 +34,7 @@ def build_user_server() -> FastMCP:
33
34
  If `app_key` is unknown, use `app_list` or `app_search` first.
34
35
  If the app is known but the data range is not, use `app_get` first and choose from `accessible_views`.
35
36
  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.
37
+ `view_get(view_id=...)` also returns `export_capability`; it only means there is a supported export route, not that export permission has been verified.
36
38
 
37
39
  ## Shared Helper
38
40
 
@@ -142,6 +144,13 @@ Use `record_code_block_run` when the user wants to execute a form code-block fie
142
144
  - Do not modify user-uploaded files unless the user explicitly authorizes repair.
143
145
  - If repair is authorized, keep the original file and repair a copy, then run `record_import_verify` again before `record_import_start`.
144
146
 
147
+ ## Export Path
148
+
149
+ `view_get -> record_export_start -> record_export_status_get -> record_export_get`
150
+
151
+ - `record_export_direct` is the one-shot path that starts export, waits, downloads locally, and still returns remote download links.
152
+ - Export v1 supports record views only and follows the same public `view_id` semantics as `record_list`.
153
+
145
154
  ## Task Workflow Path
146
155
 
147
156
  `task_list -> task_get -> task_action_execute`
@@ -188,6 +197,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
188
197
  workspace = wrap_trimmed_methods(WorkspaceTools(sessions, backend), USER_SERVER_METHOD_MAP)
189
198
  file_tools = wrap_trimmed_methods(FileTools(sessions, backend), USER_SERVER_METHOD_MAP)
190
199
  imports = wrap_trimmed_methods(ImportTools(sessions, backend), USER_SERVER_METHOD_MAP)
200
+ exports = wrap_trimmed_methods(ExportTools(sessions, backend), USER_SERVER_METHOD_MAP)
191
201
  resources = wrap_trimmed_methods(ResourceReadTools(sessions, backend), USER_SERVER_METHOD_MAP)
192
202
  feedback = FeedbackTools(backend, mcp_side="App User MCP")
193
203
  code_block_tools = wrap_trimmed_methods(CodeBlockTools(sessions, backend), USER_SERVER_METHOD_MAP)
@@ -218,6 +228,49 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
218
228
  def auth_logout(profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict:
219
229
  return auth.auth_logout(profile=profile, forget_persisted=forget_persisted)
220
230
 
231
+ @server.tool()
232
+ def record_export_start(
233
+ profile: str = DEFAULT_PROFILE,
234
+ app_key: str = "",
235
+ view_id: str = "system:all",
236
+ ) -> dict:
237
+ return exports.record_export_start(profile=profile, app_key=app_key, view_id=view_id)
238
+
239
+ @server.tool()
240
+ def record_export_status_get(
241
+ profile: str = DEFAULT_PROFILE,
242
+ export_handle: str = "",
243
+ ) -> dict:
244
+ return exports.record_export_status_get(profile=profile, export_handle=export_handle)
245
+
246
+ @server.tool()
247
+ def record_export_get(
248
+ profile: str = DEFAULT_PROFILE,
249
+ export_handle: str = "",
250
+ download_to_path: str | None = None,
251
+ ) -> dict:
252
+ return exports.record_export_get(
253
+ profile=profile,
254
+ export_handle=export_handle,
255
+ download_to_path=download_to_path,
256
+ )
257
+
258
+ @server.tool()
259
+ def record_export_direct(
260
+ profile: str = DEFAULT_PROFILE,
261
+ app_key: str = "",
262
+ view_id: str = "system:all",
263
+ download_to_path: str | None = None,
264
+ wait_timeout_seconds: float | None = None,
265
+ ) -> dict:
266
+ return exports.record_export_direct(
267
+ profile=profile,
268
+ app_key=app_key,
269
+ view_id=view_id,
270
+ download_to_path=download_to_path,
271
+ wait_timeout_seconds=wait_timeout_seconds,
272
+ )
273
+
221
274
  @server.tool()
222
275
  def workspace_list(
223
276
  profile: str = DEFAULT_PROFILE,