@josephyan/qingflow-app-user-mcp 0.2.0-beta.1004 → 0.2.0-beta.1005

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.1004
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.1005
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.1004 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.1005 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.1004",
3
+ "version": "0.2.0-beta.1005",
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.0b1004"
7
+ version = "0.2.0b1005"
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.0b1004"
8
+ _FALLBACK_VERSION = "0.2.0b1005"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import sys
4
5
  from typing import Any
5
6
 
6
7
  from ..context import CliContext
@@ -18,10 +19,20 @@ TASK_ACTION_LABELS = {
18
19
  "urge": "催办",
19
20
  }
20
21
 
22
+ TASK_ACTION_SUCCESS_LABELS = {
23
+ "approve": "已通过",
24
+ "reject": "已驳回",
25
+ "rollback": "已退回",
26
+ "transfer": "已转交",
27
+ "save_only": "已保存",
28
+ "urge": "已催办",
29
+ }
30
+
21
31
 
22
32
  def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
23
33
  parser = subparsers.add_parser("task", help="待办与流程上下文")
24
- task_subparsers = parser.add_subparsers(dest="task_command", required=True)
34
+ task_subparsers = parser.add_subparsers(dest="task_command", required=False)
35
+ parser.set_defaults(handler=_handle_task_workbench, format_hint="task_workbench", _task_parser=parser)
25
36
 
26
37
  list_parser = task_subparsers.add_parser("list", help="列出待办")
27
38
  list_parser.add_argument("--task-box", default="todo")
@@ -90,6 +101,36 @@ def _handle_list(args: argparse.Namespace, context: CliContext) -> dict:
90
101
  )
91
102
 
92
103
 
104
+ def _handle_task_workbench(args: argparse.Namespace, context: CliContext) -> dict:
105
+ if bool(args.json) or not _has_interactive_terminal(args):
106
+ _raise_task_subcommand_required(args)
107
+
108
+ banner: str | None = None
109
+ while True:
110
+ title = _build_task_workbench_list_title(banner)
111
+ selection = _choose_todo_task_interactively(args, context, tool_name="task", title=title)
112
+ banner = None
113
+ if selection.status == "empty":
114
+ return {"status": "success", "message": "当前没有待办"}
115
+ if selection.status == "cancelled":
116
+ return cancelled_result("已退出")
117
+ if selection.status != "selected":
118
+ _raise_task_subcommand_required(args)
119
+
120
+ args.task_id = str(selection.value or "")
121
+ args.app_key = ""
122
+ args.record_id = ""
123
+ args.workflow_node_id = 0
124
+
125
+ outcome, payload = _run_task_workbench_task_loop(args, context)
126
+ if outcome == "refresh":
127
+ banner = str(payload or "").strip() or None
128
+ continue
129
+ if outcome == "back":
130
+ continue
131
+ return payload if isinstance(payload, dict) else cancelled_result("已退出")
132
+
133
+
93
134
  def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
94
135
  selection_result = _resolve_task_locator_or_select(args, context, tool_name="task get")
95
136
  if isinstance(selection_result, dict):
@@ -165,6 +206,50 @@ def _handle_report(args: argparse.Namespace, context: CliContext) -> dict:
165
206
  )
166
207
 
167
208
 
209
+ def _run_task_workbench_task_loop(args: argparse.Namespace, context: CliContext) -> tuple[str, dict | str]:
210
+ while True:
211
+ task_context = _load_task_workbench_context(args, context)
212
+ detail_selection = _show_task_workbench_detail(args, task_context)
213
+ if detail_selection.status == "cancelled":
214
+ return "back", ""
215
+ if str(detail_selection.value or "") == "exit":
216
+ return "result", cancelled_result("已退出")
217
+ if str(detail_selection.value or "") != "action":
218
+ 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":
229
+ continue
230
+ if action_selection.status != "selected":
231
+ return "result", cancelled_result("已退出")
232
+
233
+ args.action = str(action_selection.value or "").strip()
234
+ payload_selection = _resolve_action_payload_or_select(args, task_context, payload={})
235
+ if _is_cancelled_result(payload_selection):
236
+ continue
237
+
238
+ result = context.task.task_action_execute(
239
+ profile=args.profile,
240
+ task_id=args.task_id,
241
+ app_key=args.app_key or "",
242
+ record_id=args.record_id or "",
243
+ workflow_node_id=int(args.workflow_node_id or 0),
244
+ action=args.action,
245
+ payload=payload_selection,
246
+ fields={},
247
+ )
248
+ if _is_success_result(result):
249
+ return "refresh", _task_action_success_label(str(args.action or "").strip().lower())
250
+ return "result", result
251
+
252
+
168
253
  def _resolve_task_locator_or_select(args: argparse.Namespace, context: CliContext, *, tool_name: str) -> dict | None:
169
254
  if (args.task_id or "").strip():
170
255
  return None
@@ -200,7 +285,13 @@ def _resolve_task_locator_or_select(args: argparse.Namespace, context: CliContex
200
285
  return None
201
286
 
202
287
 
203
- def _choose_todo_task_interactively(args: argparse.Namespace, context: CliContext, *, tool_name: str):
288
+ def _choose_todo_task_interactively(
289
+ args: argparse.Namespace,
290
+ context: CliContext,
291
+ *,
292
+ tool_name: str,
293
+ title: str = "选择待办",
294
+ ):
204
295
  def load_options() -> list[SelectionOption[str]]:
205
296
  result = context.task.task_list(
206
297
  profile=args.profile,
@@ -244,7 +335,7 @@ def _choose_todo_task_interactively(args: argparse.Namespace, context: CliContex
244
335
 
245
336
  return resolve_interactive_selection(
246
337
  args,
247
- title="选择待办",
338
+ title=title,
248
339
  unavailable_message=(
249
340
  f"{tool_name} requires --task-id, or --app-key together with --record-id and --workflow-node-id"
250
341
  ),
@@ -287,6 +378,18 @@ def _load_task_action_context(args: argparse.Namespace, context: CliContext) ->
287
378
  )
288
379
 
289
380
 
381
+ def _load_task_workbench_context(args: argparse.Namespace, context: CliContext) -> dict[str, Any]:
382
+ return context.task.task_get(
383
+ profile=args.profile,
384
+ task_id=args.task_id,
385
+ app_key=args.app_key or "",
386
+ record_id=args.record_id or "",
387
+ workflow_node_id=int(args.workflow_node_id or 0),
388
+ include_candidates=True,
389
+ include_associated_reports=True,
390
+ )
391
+
392
+
290
393
  def _resolve_task_action_or_select(
291
394
  args: argparse.Namespace,
292
395
  task_context: dict[str, Any],
@@ -316,6 +419,7 @@ def _choose_task_action_interactively(
316
419
  task_context: dict[str, Any],
317
420
  *,
318
421
  fields: dict[str, Any],
422
+ title: str = "选择操作",
319
423
  ):
320
424
  data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
321
425
  available_actions = [str(item).strip() for item in (data.get("available_actions") or []) if str(item).strip()]
@@ -349,13 +453,36 @@ def _choose_task_action_interactively(
349
453
 
350
454
  return resolve_interactive_selection(
351
455
  args,
352
- title="选择操作",
456
+ title=title,
353
457
  unavailable_message="task action requires --action, or an interactive terminal to choose the current task action",
354
458
  empty_message="task action could not open an action selector because no interactive actions are available.",
355
459
  load_options=load_options,
356
460
  )
357
461
 
358
462
 
463
+ def _show_task_workbench_detail(args: argparse.Namespace, task_context: dict[str, Any]):
464
+ has_actions = _task_has_interactive_actions(task_context)
465
+
466
+ 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
+ ]
472
+ return [
473
+ SelectionOption(value="back", label="返回列表", hint="当前节点没有可执行动作"),
474
+ SelectionOption(value="exit", label="退出工作台", hint="结束当前待办工作台"),
475
+ ]
476
+
477
+ return resolve_interactive_selection(
478
+ args,
479
+ title=_build_task_detail_title(task_context),
480
+ unavailable_message="task workbench requires an interactive terminal",
481
+ empty_message="task workbench could not open the task detail view.",
482
+ load_options=load_options,
483
+ )
484
+
485
+
359
486
  def _resolve_action_payload_or_select(
360
487
  args: argparse.Namespace,
361
488
  task_context: dict[str, Any],
@@ -513,6 +640,100 @@ def _is_cancelled_result(value: Any) -> bool:
513
640
  return isinstance(value, dict) and str(value.get("status") or "").strip().lower() == "cancelled"
514
641
 
515
642
 
643
+ def _is_success_result(result: dict[str, Any]) -> bool:
644
+ if not isinstance(result, dict):
645
+ return False
646
+ if result.get("ok") is False:
647
+ return False
648
+ return str(result.get("status") or "").strip().lower() == "success"
649
+
650
+
651
+ def _task_action_success_label(action: str) -> str:
652
+ return TASK_ACTION_SUCCESS_LABELS.get(action, "已完成")
653
+
654
+
655
+ def _build_task_workbench_list_title(banner: str | None) -> str:
656
+ lines = ["待办工作台", "选择一条待办后查看详情并执行操作"]
657
+ if banner:
658
+ lines.extend(["", f"上一条结果:{banner}"])
659
+ return "\n".join(lines)
660
+
661
+
662
+ def _build_task_detail_title(task_context: dict[str, Any]) -> str:
663
+ data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
664
+ task = data.get("task") if isinstance(data.get("task"), dict) else {}
665
+ record_summary = data.get("record_summary") if isinstance(data.get("record_summary"), dict) else {}
666
+ available_actions = data.get("available_actions") if isinstance(data.get("available_actions"), list) else []
667
+ editable_fields = data.get("editable_fields") if isinstance(data.get("editable_fields"), list) else []
668
+ extras = data.get("extras") if isinstance(data.get("extras"), dict) else {}
669
+ initiator = task.get("initiator") if isinstance(task.get("initiator"), dict) else {}
670
+ core_fields = record_summary.get("core_fields") if isinstance(record_summary.get("core_fields"), dict) else {}
671
+ action_labels = ", ".join(TASK_ACTION_LABELS.get(str(item), str(item)) for item in available_actions if str(item).strip()) or "-"
672
+ lines = [
673
+ "待办详情",
674
+ f"Task ID: {task.get('task_id') or '-'}",
675
+ f"应用: {task.get('app_name') or '-'}",
676
+ f"节点: {task.get('workflow_node_name') or '-'}",
677
+ f"发起人: {_task_initiator_label(initiator)}",
678
+ f"状态: {record_summary.get('apply_status') or '-'}",
679
+ f"可执行动作: {action_labels}",
680
+ f"可编辑字段: {len(editable_fields)}",
681
+ (
682
+ "附加信息: "
683
+ f"报表 {_count_candidate_items(extras.get('associated_reports'))} / "
684
+ f"退回 {_count_candidate_items(extras.get('rollback_candidates'))} / "
685
+ f"转交 {_count_candidate_items(extras.get('transfer_candidates'))}"
686
+ ),
687
+ ]
688
+ if core_fields:
689
+ lines.append("")
690
+ lines.append("摘要字段:")
691
+ for key, value in list(core_fields.items())[:6]:
692
+ lines.append(f"- {key}: {_short_display(value)}")
693
+ return "\n".join(lines)
694
+
695
+
696
+ def _build_task_action_title(task_context: dict[str, Any]) -> str:
697
+ return _build_task_detail_title(task_context) + "\n\n选择操作"
698
+
699
+
700
+ def _task_initiator_label(initiator: dict[str, Any]) -> str:
701
+ for key in ("display_name", "displayName", "name", "email", "uid"):
702
+ value = initiator.get(key)
703
+ if value not in (None, ""):
704
+ return str(value)
705
+ return "-"
706
+
707
+
708
+ def _task_has_interactive_actions(task_context: dict[str, Any]) -> bool:
709
+ data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
710
+ available_actions = [str(item).strip() for item in (data.get("available_actions") or []) if str(item).strip()]
711
+ return any(action != "save_only" for action in available_actions)
712
+
713
+
714
+ def _short_display(value: Any, *, limit: int = 60) -> str:
715
+ text = str(value if value not in (None, "") else "-")
716
+ if len(text) <= limit:
717
+ return text
718
+ return text[: limit - 1] + "…"
719
+
720
+
721
+ def _has_interactive_terminal(args: argparse.Namespace) -> bool:
722
+ input_stream = getattr(args, "_stdin", None)
723
+ return bool(getattr(input_stream, "isatty", lambda: False)())
724
+
725
+
726
+ def _raise_task_subcommand_required(args: argparse.Namespace) -> None:
727
+ stream = getattr(args, "_stderr_stream", None) or sys.stderr
728
+ task_parser = getattr(args, "_task_parser", None)
729
+ if isinstance(task_parser, argparse.ArgumentParser):
730
+ task_parser.print_usage(stream)
731
+ stream.write(f"{task_parser.prog}: error: the following arguments are required: task_command\n")
732
+ else:
733
+ stream.write("qingflow task: error: the following arguments are required: task_command\n")
734
+ raise SystemExit(2)
735
+
736
+
516
737
  def _choose_associated_report_interactively(args: argparse.Namespace, context: CliContext):
517
738
  def load_options() -> list[SelectionOption[int]]:
518
739
  result = context.task.task_get(
@@ -296,6 +296,13 @@ def _format_task_action(result: dict[str, Any]) -> str:
296
296
  return _task_action_success_label(action) + "\n"
297
297
 
298
298
 
299
+ def _format_task_workbench(result: dict[str, Any]) -> str:
300
+ message = str(result.get("message") or "").strip()
301
+ if message:
302
+ return message + "\n"
303
+ return ""
304
+
305
+
299
306
  def _format_task_associated_report_detail(result: dict[str, Any]) -> str:
300
307
  data = result.get("data") if isinstance(result.get("data"), dict) else {}
301
308
  selection = data.get("selection") if isinstance(data.get("selection"), dict) else {}
@@ -588,6 +595,7 @@ _FORMATTERS = {
588
595
  "app_get": _format_app_get,
589
596
  "record_list": _format_record_list,
590
597
  "task_list": _format_task_list,
598
+ "task_workbench": _format_task_workbench,
591
599
  "task_get": _format_task_get,
592
600
  "task_action_execute": _format_task_action,
593
601
  "task_associated_report_detail_get": _format_task_associated_report_detail,
@@ -56,6 +56,8 @@ def run(
56
56
  if not bool(args.json):
57
57
  _emit_cli_effective_context_notice(args, context, stream=err)
58
58
  result = handler(args, context)
59
+ except SystemExit as exc:
60
+ return int(exc.code or 0)
59
61
  except RuntimeError as exc:
60
62
  payload = trim_error_response(_parse_error_payload(exc))
61
63
  return _emit_error(payload, json_mode=bool(args.json), stdout=out, stderr=err)