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

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.1003
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.1004
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.1003 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.1004 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.1003",
3
+ "version": "0.2.0-beta.1004",
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.0b1003"
7
+ version = "0.2.0b1004"
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.0b1003"
8
+ _FALLBACK_VERSION = "0.2.0b1004"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -1,13 +1,24 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ from typing import Any
4
5
 
5
6
  from ..context import CliContext
6
- from ..interaction import cancelled_result, resolve_interactive_selection
7
+ from ..interaction import cancelled_result, resolve_interactive_selection, resolve_interactive_text_input
7
8
  from ..terminal_ui import SelectionOption
8
9
  from .common import load_object_arg, raise_config_error
9
10
 
10
11
 
12
+ TASK_ACTION_LABELS = {
13
+ "approve": "通过",
14
+ "reject": "驳回",
15
+ "rollback": "退回",
16
+ "transfer": "转交",
17
+ "save_only": "仅保存",
18
+ "urge": "催办",
19
+ }
20
+
21
+
11
22
  def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
12
23
  parser = subparsers.add_parser("task", help="待办与流程上下文")
13
24
  task_subparsers = parser.add_subparsers(dest="task_command", required=True)
@@ -39,7 +50,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
39
50
  action.add_argument("--app-key")
40
51
  action.add_argument("--record-id")
41
52
  action.add_argument("--workflow-node-id", type=int)
42
- action.add_argument("--action", required=True)
53
+ action.add_argument("--action", help="不传时在交互终端中选择当前待办可执行动作")
43
54
  action.add_argument("--payload-file")
44
55
  action.add_argument("--fields-file")
45
56
  action.set_defaults(
@@ -95,10 +106,21 @@ def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
95
106
 
96
107
 
97
108
  def _handle_action(args: argparse.Namespace, context: CliContext) -> dict:
98
- if not args.task_id and not (args.app_key and args.record_id and args.workflow_node_id):
99
- raise RuntimeError(
100
- '{"category":"config","message":"task action requires --task-id, or --app-key together with --record-id and --workflow-node-id"}'
101
- )
109
+ selection_result = _resolve_task_locator_or_select(args, context, tool_name="task action")
110
+ if isinstance(selection_result, dict):
111
+ return selection_result
112
+ payload = load_object_arg(args.payload_file, option_name="--payload-file") or {}
113
+ fields = load_object_arg(args.fields_file, option_name="--fields-file") or {}
114
+ task_context: dict[str, Any] | None = None
115
+ if not str(args.action or "").strip():
116
+ task_context = _load_task_action_context(args, context)
117
+ action_selection = _resolve_task_action_or_select(args, task_context, fields=fields)
118
+ if isinstance(action_selection, dict):
119
+ return action_selection
120
+ payload_selection = _resolve_action_payload_or_select(args, task_context, payload=payload)
121
+ if _is_cancelled_result(payload_selection):
122
+ return payload_selection
123
+ payload = payload_selection
102
124
  return context.task.task_action_execute(
103
125
  profile=args.profile,
104
126
  task_id=args.task_id,
@@ -106,8 +128,8 @@ def _handle_action(args: argparse.Namespace, context: CliContext) -> dict:
106
128
  record_id=args.record_id or "",
107
129
  workflow_node_id=int(args.workflow_node_id or 0),
108
130
  action=args.action,
109
- payload=load_object_arg(args.payload_file, option_name="--payload-file") or {},
110
- fields=load_object_arg(args.fields_file, option_name="--fields-file") or {},
131
+ payload=payload,
132
+ fields=fields,
111
133
  )
112
134
 
113
135
 
@@ -253,6 +275,244 @@ def _resolve_report_id_or_select(args: argparse.Namespace, context: CliContext)
253
275
  return None
254
276
 
255
277
 
278
+ def _load_task_action_context(args: argparse.Namespace, context: CliContext) -> dict[str, Any]:
279
+ return context.task.task_get(
280
+ profile=args.profile,
281
+ task_id=args.task_id,
282
+ app_key=args.app_key or "",
283
+ record_id=args.record_id or "",
284
+ workflow_node_id=int(args.workflow_node_id or 0),
285
+ include_candidates=True,
286
+ include_associated_reports=False,
287
+ )
288
+
289
+
290
+ def _resolve_task_action_or_select(
291
+ args: argparse.Namespace,
292
+ task_context: dict[str, Any],
293
+ *,
294
+ fields: dict[str, Any],
295
+ ) -> dict | None:
296
+ selection = _choose_task_action_interactively(args, task_context, fields=fields)
297
+ if selection.status == "unavailable":
298
+ raise_config_error(
299
+ "task action requires --action, or an interactive terminal to choose the current task action",
300
+ fix_hint="Pass `--action ACTION`, or run `qingflow task action` in an interactive terminal and choose from available actions.",
301
+ )
302
+ if selection.status == "empty":
303
+ raise_config_error(
304
+ selection.message or "task action could not open an action selector because no interactive actions are available.",
305
+ fix_hint="Run `task get --task-id TASK_ID` to inspect available_actions, or pass a supported `--action` explicitly.",
306
+ )
307
+ if selection.status == "cancelled":
308
+ return cancelled_result(selection.message or "已取消")
309
+
310
+ args.action = str(selection.value or "").strip()
311
+ return None
312
+
313
+
314
+ def _choose_task_action_interactively(
315
+ args: argparse.Namespace,
316
+ task_context: dict[str, Any],
317
+ *,
318
+ fields: dict[str, Any],
319
+ ):
320
+ data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
321
+ available_actions = [str(item).strip() for item in (data.get("available_actions") or []) if str(item).strip()]
322
+ extras = data.get("extras") if isinstance(data.get("extras"), dict) else {}
323
+ action_metadata = data.get("action_metadata") if isinstance(data.get("action_metadata"), dict) else {}
324
+ feedback_required = {
325
+ str(item).strip()
326
+ for item in (action_metadata.get("feedback_required_for") or [])
327
+ if str(item).strip()
328
+ }
329
+ rollback_count = _count_candidate_items(extras.get("rollback_candidates"))
330
+ transfer_count = _count_candidate_items(extras.get("transfer_candidates"))
331
+
332
+ def load_options() -> list[SelectionOption[str]]:
333
+ options: list[SelectionOption[str]] = []
334
+ for action in available_actions:
335
+ if action == "save_only" and not fields:
336
+ continue
337
+ label = TASK_ACTION_LABELS.get(action, action)
338
+ hint_parts = [action]
339
+ if action in feedback_required:
340
+ hint_parts.append("需要理由")
341
+ if action == "rollback":
342
+ hint_parts.append(f"可退回节点 {rollback_count}")
343
+ if action == "transfer":
344
+ hint_parts.append(f"可转交成员 {transfer_count}")
345
+ if action == "save_only":
346
+ hint_parts.append("仅保存字段,不推进流程")
347
+ options.append(SelectionOption(value=action, label=label, hint=" · ".join(hint_parts)))
348
+ return options
349
+
350
+ return resolve_interactive_selection(
351
+ args,
352
+ title="选择操作",
353
+ unavailable_message="task action requires --action, or an interactive terminal to choose the current task action",
354
+ empty_message="task action could not open an action selector because no interactive actions are available.",
355
+ load_options=load_options,
356
+ )
357
+
358
+
359
+ def _resolve_action_payload_or_select(
360
+ args: argparse.Namespace,
361
+ task_context: dict[str, Any],
362
+ *,
363
+ payload: dict[str, Any],
364
+ ) -> dict[str, Any] | dict[str, str]:
365
+ action = str(args.action or "").strip().lower()
366
+ if action == "reject" and not _extract_audit_feedback(payload):
367
+ feedback = resolve_interactive_text_input(
368
+ args,
369
+ prompt="请输入驳回原因并回车,留空取消: ",
370
+ unavailable_message="task reject requires payload.audit_feedback, or an interactive terminal to enter a reject reason",
371
+ )
372
+ if feedback.status == "unavailable":
373
+ raise_config_error(
374
+ "task reject requires payload.audit_feedback, or an interactive terminal to enter a reject reason",
375
+ fix_hint="Pass `--payload-file` with `{\"audit_feedback\": \"...\"}`, or retry in an interactive terminal.",
376
+ )
377
+ if feedback.status == "cancelled":
378
+ return cancelled_result(feedback.message or "已取消")
379
+ payload["audit_feedback"] = str(feedback.value or "").strip()
380
+ return payload
381
+
382
+ if action == "rollback" and _extract_positive_int(payload, "target_workflow_node_id", aliases=("targetAuditNodeId", "targetWorkflowNodeId")) <= 0:
383
+ selection = _choose_rollback_candidate_interactively(args, task_context)
384
+ if selection.status == "unavailable":
385
+ raise_config_error(
386
+ "task rollback requires target_workflow_node_id, or an interactive terminal to choose a rollback node",
387
+ fix_hint="Pass `--payload-file` with `{\"target_workflow_node_id\": NODE_ID}`, or retry in an interactive terminal.",
388
+ )
389
+ if selection.status == "empty":
390
+ raise_config_error(
391
+ selection.message or "task rollback could not open a selector because no rollback candidates are visible.",
392
+ fix_hint="Run `task get --task-id TASK_ID` to inspect rollback candidates, or choose another action.",
393
+ )
394
+ if selection.status == "cancelled":
395
+ return cancelled_result(selection.message or "已取消")
396
+ payload["target_workflow_node_id"] = int(selection.value or 0)
397
+ return payload
398
+
399
+ if action == "transfer" and _extract_positive_int(payload, "target_member_id", aliases=("uid", "targetMemberId")) <= 0:
400
+ selection = _choose_transfer_candidate_interactively(args, task_context)
401
+ if selection.status == "unavailable":
402
+ raise_config_error(
403
+ "task transfer requires target_member_id, or an interactive terminal to choose a transfer member",
404
+ fix_hint="Pass `--payload-file` with `{\"target_member_id\": UID}`, or retry in an interactive terminal.",
405
+ )
406
+ if selection.status == "empty":
407
+ raise_config_error(
408
+ selection.message or "task transfer could not open a selector because no transfer candidates are visible.",
409
+ fix_hint="Run `task get --task-id TASK_ID` to inspect transfer candidates, or choose another action.",
410
+ )
411
+ if selection.status == "cancelled":
412
+ return cancelled_result(selection.message or "已取消")
413
+ payload["target_member_id"] = int(selection.value or 0)
414
+ return payload
415
+
416
+ return payload
417
+
418
+
419
+ def _choose_rollback_candidate_interactively(args: argparse.Namespace, task_context: dict[str, Any]):
420
+ extras = _task_context_extras(task_context)
421
+
422
+ def load_options() -> list[SelectionOption[int]]:
423
+ rollback_candidates = extras.get("rollback_candidates") if isinstance(extras.get("rollback_candidates"), dict) else {}
424
+ items = rollback_candidates.get("items") if isinstance(rollback_candidates.get("items"), list) else []
425
+ options: list[SelectionOption[int]] = []
426
+ for item in items:
427
+ if not isinstance(item, dict):
428
+ continue
429
+ workflow_node_id = int(item.get("workflow_node_id") or 0)
430
+ if workflow_node_id <= 0:
431
+ continue
432
+ workflow_node_name = str(item.get("workflow_node_name") or f"节点 {workflow_node_id}").strip() or f"节点 {workflow_node_id}"
433
+ options.append(
434
+ SelectionOption(
435
+ value=workflow_node_id,
436
+ label=workflow_node_name,
437
+ hint=f"workflow_node_id={workflow_node_id}",
438
+ )
439
+ )
440
+ return options
441
+
442
+ return resolve_interactive_selection(
443
+ args,
444
+ title="选择退回节点",
445
+ unavailable_message="task rollback requires target_workflow_node_id, or an interactive terminal to choose a rollback node",
446
+ empty_message="task rollback could not open a selector because no rollback candidates are visible.",
447
+ load_options=load_options,
448
+ )
449
+
450
+
451
+ def _choose_transfer_candidate_interactively(args: argparse.Namespace, task_context: dict[str, Any]):
452
+ extras = _task_context_extras(task_context)
453
+
454
+ def load_options() -> list[SelectionOption[int]]:
455
+ transfer_candidates = extras.get("transfer_candidates") if isinstance(extras.get("transfer_candidates"), dict) else {}
456
+ items = transfer_candidates.get("items") if isinstance(transfer_candidates.get("items"), list) else []
457
+ options: list[SelectionOption[int]] = []
458
+ for item in items:
459
+ if not isinstance(item, dict):
460
+ continue
461
+ uid = int(item.get("uid") or 0)
462
+ if uid <= 0:
463
+ continue
464
+ name = str(item.get("name") or f"成员 {uid}").strip() or f"成员 {uid}"
465
+ hint_parts = [f"uid={uid}"]
466
+ department_name = str(item.get("department_name") or "").strip()
467
+ email = str(item.get("email") or "").strip()
468
+ if department_name:
469
+ hint_parts.append(department_name)
470
+ if email:
471
+ hint_parts.append(email)
472
+ options.append(SelectionOption(value=uid, label=name, hint=" · ".join(hint_parts)))
473
+ return options
474
+
475
+ return resolve_interactive_selection(
476
+ args,
477
+ title="选择转交成员",
478
+ unavailable_message="task transfer requires target_member_id, or an interactive terminal to choose a transfer member",
479
+ empty_message="task transfer could not open a selector because no transfer candidates are visible.",
480
+ load_options=load_options,
481
+ )
482
+
483
+
484
+ def _task_context_extras(task_context: dict[str, Any]) -> dict[str, Any]:
485
+ data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
486
+ return data.get("extras") if isinstance(data.get("extras"), dict) else {}
487
+
488
+
489
+ def _count_candidate_items(value: Any) -> int:
490
+ if not isinstance(value, dict):
491
+ return 0
492
+ items = value.get("items")
493
+ return len(items) if isinstance(items, list) else 0
494
+
495
+
496
+ def _extract_audit_feedback(payload: dict[str, Any]) -> str | None:
497
+ for key in ("audit_feedback", "auditFeedback"):
498
+ value = payload.get(key)
499
+ if isinstance(value, str) and value.strip():
500
+ return value.strip()
501
+ return None
502
+
503
+
504
+ def _extract_positive_int(payload: dict[str, Any], key: str, *, aliases: tuple[str, ...] = ()) -> int:
505
+ for candidate in (key, *aliases):
506
+ value = payload.get(candidate)
507
+ if isinstance(value, int) and value > 0:
508
+ return value
509
+ return 0
510
+
511
+
512
+ def _is_cancelled_result(value: Any) -> bool:
513
+ return isinstance(value, dict) and str(value.get("status") or "").strip().lower() == "cancelled"
514
+
515
+
256
516
  def _choose_associated_report_interactively(args: argparse.Namespace, context: CliContext):
257
517
  def load_options() -> list[SelectionOption[int]]:
258
518
  result = context.task.task_get(
@@ -48,3 +48,25 @@ def resolve_interactive_selection(
48
48
  if value is None:
49
49
  return InteractiveSelectionResult(status="cancelled", message="已取消")
50
50
  return InteractiveSelectionResult(status="selected", value=value)
51
+
52
+
53
+ def resolve_interactive_text_input(
54
+ args: object,
55
+ *,
56
+ prompt: str,
57
+ unavailable_message: str,
58
+ ) -> InteractiveSelectionResult[str]:
59
+ input_stream = getattr(args, "_stdin", None)
60
+ output_stream = getattr(args, "_stderr_stream", None)
61
+ if input_stream is None or output_stream is None:
62
+ return InteractiveSelectionResult(status="unavailable", message=unavailable_message)
63
+ if not bool(getattr(input_stream, "isatty", lambda: False)()):
64
+ return InteractiveSelectionResult(status="unavailable", message=unavailable_message)
65
+
66
+ output_stream.write(prompt)
67
+ output_stream.flush()
68
+ line = input_stream.readline()
69
+ text = str(line or "").strip()
70
+ if not text:
71
+ return InteractiveSelectionResult(status="cancelled", message="已取消")
72
+ return InteractiveSelectionResult(status="selected", value=text)