@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 +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/backend_client.py +109 -0
- package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
- package/src/qingflow_mcp/cli/commands/exports.py +64 -0
- package/src/qingflow_mcp/cli/commands/task.py +37 -56
- package/src/qingflow_mcp/cli/context.py +3 -0
- package/src/qingflow_mcp/cli/formatters.py +56 -0
- package/src/qingflow_mcp/export_store.py +14 -0
- package/src/qingflow_mcp/public_surface.py +4 -0
- package/src/qingflow_mcp/response_trim.py +14 -0
- package/src/qingflow_mcp/server.py +10 -0
- package/src/qingflow_mcp/server_app_user.py +53 -0
- package/src/qingflow_mcp/tools/export_tools.py +960 -0
- package/src/qingflow_mcp/tools/resource_read_tools.py +30 -1
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.
|
|
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.
|
|
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
package/pyproject.toml
CHANGED
|
@@ -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
|
-
|
|
215
|
+
selected_value = str(detail_selection.value or "").strip()
|
|
216
|
+
if selected_value == "exit":
|
|
216
217
|
return "result", cancelled_result("已退出")
|
|
217
|
-
if
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
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
|
-
|
|
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,
|