@josephyan/qingflow-app-user-mcp 0.2.0-beta.995 → 0.2.0-beta.997

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.995
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.997
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.995 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.997 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.995",
3
+ "version": "0.2.0-beta.997",
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.0b995"
7
+ version = "0.2.0b997"
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.0b995"
8
+ _FALLBACK_VERSION = "0.2.0b997"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -14419,6 +14419,8 @@ def _summarize_views(result: Any) -> list[dict[str, Any]]:
14419
14419
  view_type = _normalize_view_type_name(view.get("viewgraphType") or view.get("type"))
14420
14420
  columns = view.get("columnNames") or view.get("columns") or []
14421
14421
  group_by = view.get("groupBy") or view.get("group_by")
14422
+ if not any((name, view_type, columns, group_by)) and str(view_key or "").isdigit():
14423
+ continue
14422
14424
  if not any((name, view_key, view_type, columns, group_by)):
14423
14425
  continue
14424
14426
  items.append(
@@ -14461,11 +14463,24 @@ def _summarize_views_with_config(views_tool: ViewTools, *, profile: str, views:
14461
14463
  enriched_items.append(item)
14462
14464
  continue
14463
14465
  config = config_response.get("result") if isinstance(config_response.get("result"), dict) else {}
14464
- enriched_items.append(_merge_view_summary_with_config(item, config=config))
14466
+ question_list: list[dict[str, Any]] = []
14467
+ try:
14468
+ question_response = views_tool.view_list_questions(profile=profile, viewgraph_key=view_key)
14469
+ raw_question_list = question_response.get("result")
14470
+ if isinstance(raw_question_list, list):
14471
+ question_list = [deepcopy(entry) for entry in raw_question_list if isinstance(entry, dict)]
14472
+ except (QingflowApiError, RuntimeError):
14473
+ question_list = []
14474
+ enriched_items.append(_merge_view_summary_with_config(item, config=config, question_list=question_list))
14465
14475
  return enriched_items, config_read_errors
14466
14476
 
14467
14477
 
14468
- def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, Any]) -> dict[str, Any]:
14478
+ def _merge_view_summary_with_config(
14479
+ base: dict[str, Any],
14480
+ *,
14481
+ config: dict[str, Any],
14482
+ question_list: list[dict[str, Any]] | None = None,
14483
+ ) -> dict[str, Any]:
14469
14484
  summary = deepcopy(base)
14470
14485
  if not isinstance(config, dict) or not config:
14471
14486
  return summary
@@ -14473,6 +14488,7 @@ def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, A
14473
14488
  summary["visibility_summary"] = _visibility_summary(_public_visibility_from_member_auth(config.get("auth")))
14474
14489
  legacy_columns = [str(value) for value in (summary.get("columns") or []) if str(value or "").strip()]
14475
14490
  question_entries = _extract_view_question_entries(config.get("viewgraphQuestions"))
14491
+ canonical_question_entries = _extract_view_question_entries(question_list)
14476
14492
  question_entries_by_id = {
14477
14493
  field_id: entry
14478
14494
  for entry in question_entries
@@ -14492,19 +14508,20 @@ def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, A
14492
14508
  display_entries = _sort_view_question_entries(
14493
14509
  [entry for entry in question_entries if bool(entry.get("visible", True))],
14494
14510
  )
14511
+ public_display_entries = _filter_public_view_display_entries(display_entries, configured_column_ids=configured_column_ids)
14495
14512
  display_column_ids = [
14496
14513
  field_id
14497
- for field_id in (_coerce_nonnegative_int(entry.get("field_id")) for entry in display_entries)
14514
+ for field_id in (_coerce_nonnegative_int(entry.get("field_id")) for entry in public_display_entries)
14498
14515
  if field_id is not None
14499
14516
  ]
14500
14517
  display_columns = [
14501
14518
  str(entry.get("name") or "").strip()
14502
- for entry in display_entries
14519
+ for entry in public_display_entries
14503
14520
  if str(entry.get("name") or "").strip()
14504
14521
  ]
14505
14522
  apply_entries = [
14506
14523
  entry
14507
- for entry in display_entries
14524
+ for entry in public_display_entries
14508
14525
  if _coerce_nonnegative_int(entry.get("field_id")) is not None
14509
14526
  and str(entry.get("name") or "").strip()
14510
14527
  and str(entry.get("name") or "").strip() not in _KNOWN_SYSTEM_VIEW_COLUMNS
@@ -14551,8 +14568,42 @@ def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, A
14551
14568
  summary["apply_columns"] = apply_columns
14552
14569
  summary["apply_column_ids"] = apply_column_ids
14553
14570
  config_enriched = True
14554
- if question_entries:
14555
- summary["column_details"] = display_entries or _sort_view_question_entries(question_entries)
14571
+ if canonical_question_entries:
14572
+ canonical_display_entries = _sort_view_question_entries(canonical_question_entries)
14573
+ canonical_display_column_ids = [
14574
+ field_id
14575
+ for field_id in (_coerce_nonnegative_int(entry.get("field_id")) for entry in canonical_display_entries)
14576
+ if field_id is not None
14577
+ ]
14578
+ canonical_display_columns = [
14579
+ str(entry.get("name") or "").strip()
14580
+ for entry in canonical_display_entries
14581
+ if str(entry.get("name") or "").strip()
14582
+ ]
14583
+ canonical_apply_entries = [
14584
+ entry
14585
+ for entry in canonical_display_entries
14586
+ if _coerce_nonnegative_int(entry.get("field_id")) is not None
14587
+ and str(entry.get("name") or "").strip()
14588
+ and str(entry.get("name") or "").strip() not in _KNOWN_SYSTEM_VIEW_COLUMNS
14589
+ ]
14590
+ summary["columns"] = canonical_display_columns
14591
+ summary["display_columns"] = canonical_display_columns
14592
+ summary["display_column_ids"] = canonical_display_column_ids
14593
+ summary["column_details"] = canonical_display_entries
14594
+ summary["apply_columns"] = [
14595
+ str(entry.get("name") or "").strip()
14596
+ for entry in canonical_apply_entries
14597
+ if str(entry.get("name") or "").strip()
14598
+ ]
14599
+ summary["apply_column_ids"] = [
14600
+ field_id
14601
+ for field_id in (_coerce_nonnegative_int(entry.get("field_id")) for entry in canonical_apply_entries)
14602
+ if field_id is not None
14603
+ ]
14604
+ config_enriched = True
14605
+ elif question_entries:
14606
+ summary["column_details"] = public_display_entries or _sort_view_question_entries(question_entries)
14556
14607
  config_enriched = True
14557
14608
  display_config = _extract_view_display_config(
14558
14609
  config,
@@ -14577,7 +14628,7 @@ def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, A
14577
14628
  summary["button_read_source"] = button_source
14578
14629
  config_enriched = True
14579
14630
  if config_enriched:
14580
- summary["read_source"] = "view_config"
14631
+ summary["read_source"] = "view_config+question" if canonical_question_entries else "view_config"
14581
14632
  return summary
14582
14633
 
14583
14634
 
@@ -14585,29 +14636,64 @@ def _extract_view_question_entries(questions: Any) -> list[dict[str, Any]]:
14585
14636
  if not isinstance(questions, list):
14586
14637
  return []
14587
14638
  entries: list[dict[str, Any]] = []
14588
- for index, item in enumerate(questions, start=1):
14589
- if not isinstance(item, dict):
14590
- continue
14591
- field_id = _coerce_nonnegative_int(item.get("queId"))
14592
- name = str(item.get("queTitle") or "").strip() or None
14593
- visible_raw = item.get("beingListDisplay")
14594
- if visible_raw is None:
14595
- visible_raw = item.get("beingVisible")
14596
- visible = bool(visible_raw) if visible_raw is not None else True
14597
- display_order = _coerce_positive_int(item.get("displayOrdinal"))
14598
- entry: dict[str, Any] = {
14599
- "field_id": field_id,
14600
- "name": name,
14601
- "visible": visible,
14602
- "display_order": display_order if display_order is not None else index,
14603
- }
14604
- width = _coerce_positive_int(item.get("width"))
14605
- if width is not None:
14606
- entry["width"] = width
14607
- entries.append(entry)
14639
+ fallback_order = 0
14640
+
14641
+ def walk(nodes: Any) -> None:
14642
+ nonlocal fallback_order
14643
+ if not isinstance(nodes, list):
14644
+ return
14645
+ for item in nodes:
14646
+ if not isinstance(item, dict):
14647
+ continue
14648
+ children: list[Any] = []
14649
+ for child_key in ("innerQues", "subQues", "innerQuestions", "subQuestions"):
14650
+ child_value = item.get(child_key)
14651
+ if isinstance(child_value, list) and child_value:
14652
+ children.extend(child_value)
14653
+ if children:
14654
+ walk(children)
14655
+ continue
14656
+ field_id = _coerce_nonnegative_int(item.get("queId"))
14657
+ name = str(item.get("queTitle") or "").strip() or None
14658
+ if field_id is None and name is None:
14659
+ continue
14660
+ visible_raw = item.get("beingListDisplay")
14661
+ if visible_raw is None:
14662
+ visible_raw = item.get("beingVisible")
14663
+ visible = bool(visible_raw) if visible_raw is not None else True
14664
+ display_order = _coerce_positive_int(item.get("displayOrdinal"))
14665
+ fallback_order += 1
14666
+ entry: dict[str, Any] = {
14667
+ "field_id": field_id,
14668
+ "name": name,
14669
+ "visible": visible,
14670
+ "display_order": display_order if display_order is not None else fallback_order,
14671
+ }
14672
+ width = _coerce_positive_int(item.get("width"))
14673
+ if width is not None:
14674
+ entry["width"] = width
14675
+ entries.append(entry)
14676
+
14677
+ walk(questions)
14608
14678
  return entries
14609
14679
 
14610
14680
 
14681
+ def _filter_public_view_display_entries(
14682
+ entries: list[dict[str, Any]],
14683
+ *,
14684
+ configured_column_ids: list[int],
14685
+ ) -> list[dict[str, Any]]:
14686
+ configured_set = set(configured_column_ids)
14687
+ filtered: list[dict[str, Any]] = []
14688
+ for entry in entries:
14689
+ name = str(entry.get("name") or "").strip()
14690
+ field_id = _coerce_nonnegative_int(entry.get("field_id"))
14691
+ if name in _KNOWN_SYSTEM_VIEW_COLUMNS and field_id not in configured_set:
14692
+ continue
14693
+ filtered.append(entry)
14694
+ return filtered or entries
14695
+
14696
+
14611
14697
  def _sort_view_question_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
14612
14698
  return sorted(
14613
14699
  entries,
@@ -40,7 +40,11 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
40
40
  action.add_argument("--action", required=True)
41
41
  action.add_argument("--payload-file")
42
42
  action.add_argument("--fields-file")
43
- action.set_defaults(handler=_handle_action, format_hint="")
43
+ action.set_defaults(
44
+ handler=_handle_action,
45
+ format_hint="task_action_execute",
46
+ hide_effective_context_line=True,
47
+ )
44
48
 
45
49
  report = task_subparsers.add_parser("report", help="读取待办关联报表详情;推荐直接传 --task-id")
46
50
  report.add_argument("--task-id")
@@ -19,6 +19,10 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
19
19
  get_parser.add_argument("--ws-id", type=int, default=0)
20
20
  get_parser.set_defaults(handler=_handle_get, format_hint="workspace_get")
21
21
 
22
+ select_parser = workspace_subparsers.add_parser("select", help="切换当前工作区")
23
+ select_parser.add_argument("--ws-id", type=int, required=True)
24
+ select_parser.set_defaults(handler=_handle_select, format_hint="workspace_get")
25
+
22
26
 
23
27
  def _handle_list(args: argparse.Namespace, context: CliContext) -> dict:
24
28
  return context.workspace.workspace_list(
@@ -34,3 +38,10 @@ def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
34
38
  profile=args.profile,
35
39
  ws_id=args.ws_id if int(args.ws_id or 0) > 0 else None,
36
40
  )
41
+
42
+
43
+ def _handle_select(args: argparse.Namespace, context: CliContext) -> dict:
44
+ return context.workspace.workspace_select(
45
+ profile=args.profile,
46
+ ws_id=int(args.ws_id),
47
+ )
@@ -249,6 +249,30 @@ def _format_task_get(result: dict[str, Any]) -> str:
249
249
  return "\n".join(lines) + "\n"
250
250
 
251
251
 
252
+ def _format_task_action(result: dict[str, Any]) -> str:
253
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
254
+ action = str(data.get("action") or "").strip().lower()
255
+ status = str(result.get("status") or "").strip().lower()
256
+
257
+ if status == "failed" or result.get("ok") is False:
258
+ lines = [_task_action_failure_label(action)]
259
+ reason = _task_action_failure_reason(result)
260
+ if reason:
261
+ lines.append(f"原因:{reason}")
262
+ debug_lines = _task_action_debug_lines(result)
263
+ if debug_lines:
264
+ lines.append("调试信息:")
265
+ lines.extend(f"- {line}" for line in debug_lines)
266
+ return "\n".join(lines) + "\n"
267
+
268
+ if status == "partial_success":
269
+ lines = [_task_action_success_label(action)]
270
+ lines.append(f"说明:{_task_action_partial_success_message(result)}")
271
+ return "\n".join(lines) + "\n"
272
+
273
+ return _task_action_success_label(action) + "\n"
274
+
275
+
252
276
  def _format_task_associated_report_detail(result: dict[str, Any]) -> str:
253
277
  data = result.get("data") if isinstance(result.get("data"), dict) else {}
254
278
  selection = data.get("selection") if isinstance(data.get("selection"), dict) else {}
@@ -416,6 +440,121 @@ def _first_present(payload: dict[str, Any], *keys: str) -> Any:
416
440
  return None
417
441
 
418
442
 
443
+ def _task_action_success_label(action: str) -> str:
444
+ return {
445
+ "approve": "已通过",
446
+ "reject": "已驳回",
447
+ "rollback": "已退回",
448
+ "transfer": "已转交",
449
+ "save_only": "已保存",
450
+ "urge": "已催办",
451
+ }.get(action, "已执行")
452
+
453
+
454
+ def _task_action_failure_label(action: str) -> str:
455
+ return {
456
+ "approve": "审批失败",
457
+ "reject": "驳回失败",
458
+ "rollback": "退回失败",
459
+ "transfer": "转交失败",
460
+ "save_only": "保存失败",
461
+ "urge": "催办失败",
462
+ }.get(action, "执行失败")
463
+
464
+
465
+ def _task_action_partial_success_message(result: dict[str, Any]) -> str:
466
+ error_code = str(result.get("error_code") or "").strip().upper()
467
+ if error_code == "WORKFLOW_CONTINUATION_UNVERIFIED":
468
+ return "动作已提交,但暂未完成后续流程验证。可使用 --json 查看详细信息。"
469
+ if error_code == "TASK_ALREADY_PROCESSED":
470
+ return "当前待办已不可操作,系统判断流程可能已被其他人处理。可使用 --json 查看详细信息。"
471
+ warnings = result.get("warnings")
472
+ if isinstance(warnings, list):
473
+ for warning in warnings:
474
+ if not isinstance(warning, dict):
475
+ continue
476
+ code = str(warning.get("code") or "").strip().upper()
477
+ if code == "TASK_ALREADY_PROCESSED_UNCONFIRMED_ACTOR":
478
+ return "当前待办已不可操作,系统判断流程可能已被其他人处理。可使用 --json 查看详细信息。"
479
+ if code == "WORKFLOW_CONTINUATION_UNVERIFIED":
480
+ return "动作已提交,但暂未完成后续流程验证。可使用 --json 查看详细信息。"
481
+ return "动作已提交,但结果验证不完整。可使用 --json 查看详细信息。"
482
+
483
+
484
+ def _task_action_failure_reason(result: dict[str, Any]) -> str | None:
485
+ error_code = str(result.get("error_code") or "").strip().upper()
486
+ mapped_error = {
487
+ "TASK_CONTEXT_VISIBILITY_UNVERIFIED": "当前待办已不可操作,且系统未能确认是否已被处理。",
488
+ "TASK_SAVE_ONLY_VERIFICATION_FAILED": "保存请求已发送,但未能确认字段是否全部保存成功。",
489
+ "WORKFLOW_CONTINUATION_UNVERIFIED": "动作已提交,但暂未验证到流程继续推进。",
490
+ "TASK_ALREADY_PROCESSED": "当前待办已不可操作,系统判断流程可能已被其他人处理。",
491
+ }.get(error_code)
492
+ if mapped_error:
493
+ return mapped_error
494
+
495
+ warnings = result.get("warnings")
496
+ if isinstance(warnings, list):
497
+ for warning in warnings:
498
+ if isinstance(warning, dict) and warning.get("message"):
499
+ return str(warning.get("message"))
500
+
501
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
502
+ transport_error = data.get("transport_error") if isinstance(data.get("transport_error"), dict) else {}
503
+ backend_code = transport_error.get("backend_code")
504
+ http_status = transport_error.get("http_status")
505
+ if backend_code not in (None, ""):
506
+ return f"后端返回错误码 {backend_code}。"
507
+ if http_status not in (None, ""):
508
+ return f"请求返回 HTTP {http_status}。"
509
+
510
+ verification = result.get("verification") if isinstance(result.get("verification"), dict) else {}
511
+ record_state_error = verification.get("record_state_error") if isinstance(verification.get("record_state_error"), dict) else {}
512
+ backend_code = record_state_error.get("backend_code")
513
+ http_status = record_state_error.get("http_status")
514
+ if backend_code not in (None, ""):
515
+ return f"后端返回错误码 {backend_code}。"
516
+ if http_status not in (None, ""):
517
+ return f"请求返回 HTTP {http_status}。"
518
+ if error_code:
519
+ return f"错误码:{error_code}"
520
+ return None
521
+
522
+
523
+ def _task_action_debug_lines(result: dict[str, Any]) -> list[str]:
524
+ lines: list[str] = []
525
+ error_code = result.get("error_code")
526
+ if error_code not in (None, ""):
527
+ lines.append(f"error_code: {error_code}")
528
+
529
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
530
+ transport_error = data.get("transport_error") if isinstance(data.get("transport_error"), dict) else {}
531
+ for key in ("backend_code", "http_status", "category"):
532
+ value = transport_error.get(key)
533
+ if value not in (None, ""):
534
+ lines.append(f"{key}: {value}")
535
+
536
+ verification = result.get("verification") if isinstance(result.get("verification"), dict) else {}
537
+ for key in (
538
+ "runtime_continuation_verified",
539
+ "task_context_visibility_verified",
540
+ "fields_saved_verified",
541
+ "task_still_actionable",
542
+ "workflow_not_advanced",
543
+ "record_state_readable",
544
+ ):
545
+ if key in verification and verification.get(key) is not None:
546
+ lines.append(f"{key}: {verification.get(key)}")
547
+
548
+ record_state_error = verification.get("record_state_error") if isinstance(verification.get("record_state_error"), dict) else {}
549
+ for key in ("backend_code", "http_status", "category"):
550
+ value = record_state_error.get(key)
551
+ if value not in (None, ""):
552
+ entry = f"record_state_{key}: {value}"
553
+ if entry not in lines:
554
+ lines.append(entry)
555
+ return lines
556
+
557
+
419
558
  _FORMATTERS = {
420
559
  "auth_whoami": _format_whoami,
421
560
  "workspace_list": _format_workspace_list,
@@ -426,6 +565,7 @@ _FORMATTERS = {
426
565
  "record_list": _format_record_list,
427
566
  "task_list": _format_task_list,
428
567
  "task_get": _format_task_get,
568
+ "task_action_execute": _format_task_action,
429
569
  "task_associated_report_detail_get": _format_task_associated_report_detail,
430
570
  "import_verify": _format_import_verify,
431
571
  "import_status": _format_import_status,
@@ -152,6 +152,7 @@ def _emit_cli_effective_context_notice(args: argparse.Namespace, context: CliCon
152
152
  spec = cli_public_tool_spec_from_namespace(args)
153
153
  if spec is None or not spec.cli_show_effective_context:
154
154
  return
155
+ hide_context_line = bool(getattr(args, "hide_effective_context_line", False))
155
156
  sessions = getattr(context, "sessions", None)
156
157
  if sessions is None or not hasattr(sessions, "get_profile"):
157
158
  return
@@ -168,9 +169,13 @@ def _emit_cli_effective_context_notice(args: argparse.Namespace, context: CliCon
168
169
  workspace_label = f"{workspace_name} ({workspace_id})"
169
170
  else:
170
171
  workspace_label = str(workspace_id)
171
- lines = [f"Context: profile={profile_name} workspace={workspace_label}"]
172
+ lines: list[str] = []
173
+ if not hide_context_line:
174
+ lines.append(f"Context: profile={profile_name} workspace={workspace_label}")
172
175
  if spec.cli_context_write and profile_name == "default":
173
176
  lines.append("Warning: using default profile for a workspace-sensitive write command")
177
+ if not lines:
178
+ return
174
179
  stream.write("\n".join(lines) + "\n")
175
180
 
176
181
 
@@ -36,6 +36,7 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
36
36
  PublicToolSpec(USER_DOMAIN, "auth_logout", ("auth_logout",), ("auth", "logout")),
37
37
  PublicToolSpec(USER_DOMAIN, "workspace_list", ("workspace_list",), ("workspace", "list")),
38
38
  PublicToolSpec(USER_DOMAIN, "workspace_get", ("workspace_get",), ("workspace", "get")),
39
+ PublicToolSpec(USER_DOMAIN, "workspace_select", ("workspace_select",), ("workspace", "select")),
39
40
  PublicToolSpec(USER_DOMAIN, "app_list", ("app_list",), ("app", "list"), cli_show_effective_context=True),
40
41
  PublicToolSpec(USER_DOMAIN, "app_search", ("app_search",), ("app", "search"), cli_show_effective_context=True),
41
42
  PublicToolSpec(USER_DOMAIN, "app_get", ("app_get",), ("app", "get"), cli_show_effective_context=True),
@@ -737,7 +737,7 @@ def _register_policy(domains: tuple[str, ...], names: tuple[str, ...], transform
737
737
  _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("auth_use_credential", "auth_whoami"), _trim_auth_payload)
738
738
  _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("auth_logout",), _trim_auth_logout)
739
739
  _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("workspace_list",), _trim_workspace_list)
740
- _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("workspace_get",), _trim_workspace_get)
740
+ _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("workspace_get", "workspace_select"), _trim_workspace_get)
741
741
  _register_policy((USER_DOMAIN,), ("app_list", "app_search"), _trim_app_search_like)
742
742
  _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("app_get",), _trim_app_get)
743
743
  _register_policy((BUILDER_DOMAIN,), ("app_repair_code_blocks",), _trim_builder_list_like)
@@ -242,6 +242,16 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
242
242
  ws_id=ws_id,
243
243
  )
244
244
 
245
+ @server.tool()
246
+ def workspace_select(
247
+ profile: str = DEFAULT_PROFILE,
248
+ ws_id: int = 0,
249
+ ) -> dict:
250
+ return workspace.workspace_select(
251
+ profile=profile,
252
+ ws_id=ws_id,
253
+ )
254
+
245
255
  @server.tool()
246
256
  def app_list(profile: str = DEFAULT_PROFILE) -> dict:
247
257
  return apps.app_list(profile=profile)
@@ -729,6 +729,7 @@ class AppTools(ToolBase):
729
729
  tag_ids = item.get("tagIds") if isinstance(item.get("tagIds"), list) else []
730
730
  compact = {
731
731
  "app_key": app_key,
732
+ "app_name": title,
732
733
  "title": title,
733
734
  "form_id": item.get("formId"),
734
735
  "tag_id": package_tag_id,
@@ -7,7 +7,6 @@ from ..errors import QingflowApiError, raise_tool_error
7
7
  from ..json_types import JSONObject
8
8
  from ..list_type_labels import SYSTEM_VIEW_DEFINITIONS
9
9
  from .app_tools import _analysis_supported_for_view_type
10
- from .app_tools import AppTools
11
10
  from .base import ToolBase, tool_cn_name
12
11
  from .qingbi_report_tools import QingbiReportTools
13
12
 
@@ -25,7 +24,6 @@ class ResourceReadTools(ToolBase):
25
24
  def __init__(self, sessions, backend) -> None:
26
25
  """执行内部辅助逻辑。"""
27
26
  super().__init__(sessions, backend)
28
- self.apps = AppTools(sessions, backend)
29
27
  self.charts = QingbiReportTools(sessions, backend)
30
28
 
31
29
  @tool_cn_name("资源读取-门户列表")
@@ -153,9 +151,11 @@ class ResourceReadTools(ToolBase):
153
151
  or str(config.get("viewgraphType") or config.get("viewType") or "").strip()
154
152
  )
155
153
  resolved_app_key = str(base_info.get("appKey") or config.get("appKey") or "").strip() or None
154
+ if not resolved_app_key:
155
+ resolved_app_key = self._resolve_app_key_from_view_form(context=context, view_key=view_key)
156
156
  if not resolved_app_key:
157
157
  resolved_app_key = self._resolve_app_key_from_form_id(
158
- profile=profile,
158
+ context=context,
159
159
  form_id=_coerce_positive_int(base_info.get("formId") or config.get("formId")),
160
160
  )
161
161
  if not resolved_app_key:
@@ -198,25 +198,56 @@ class ResourceReadTools(ToolBase):
198
198
 
199
199
  def runner(session_profile, _context):
200
200
  base = self.charts.qingbi_report_get_base(profile=profile, chart_id=chart_id).get("result") or {}
201
- data = self.charts.qingbi_report_get_data(profile=profile, chart_id=chart_id, payload={}).get("result") or {}
202
- data_config = data.get("config") if isinstance(data, dict) and isinstance(data.get("config"), dict) else {}
201
+ warnings: list[JSONObject] = []
202
+ verification = {
203
+ "chart_exists": True,
204
+ "chart_data_loaded": False,
205
+ "chart_config_loaded": False,
206
+ }
207
+ data: Any = None
208
+ data_config: dict[str, Any] = {}
209
+ try:
210
+ data = self.charts.qingbi_report_get_data(profile=profile, chart_id=chart_id, payload={}).get("result") or {}
211
+ verification["chart_data_loaded"] = True
212
+ if isinstance(data, dict) and isinstance(data.get("config"), dict):
213
+ data_config = deepcopy(data.get("config"))
214
+ verification["chart_config_loaded"] = True
215
+ except (QingflowApiError, RuntimeError) as error:
216
+ api_error = error if isinstance(error, QingflowApiError) else None
217
+ warnings.append(
218
+ {
219
+ "code": "CHART_DATA_UNAVAILABLE",
220
+ "message": "chart_get could not load chart data; returning chart metadata and config when available.",
221
+ "backend_code": api_error.backend_code if api_error is not None else None,
222
+ "http_status": api_error.http_status if api_error is not None else None,
223
+ }
224
+ )
225
+ if not data_config:
226
+ try:
227
+ config_result = self.charts.qingbi_report_get_config(profile=profile, chart_id=chart_id).get("result") or {}
228
+ except (QingflowApiError, RuntimeError):
229
+ config_result = {}
230
+ if isinstance(config_result, dict) and config_result:
231
+ data_config = deepcopy(config_result)
232
+ verification["chart_config_loaded"] = True
233
+ data_payload: dict[str, Any] = {
234
+ "chart_id": chart_id,
235
+ "chart_name": str(base.get("chartName") or base.get("name") or chart_id).strip() or chart_id,
236
+ "chart_type": str(base.get("chartType") or data_config.get("chartType") or "").strip() or None,
237
+ "data_source_type": str(base.get("dataSourceType") or "").strip() or None,
238
+ "data_source_id": str(base.get("dataSourceId") or "").strip() or None,
239
+ }
240
+ if verification["chart_data_loaded"]:
241
+ data_payload["data"] = deepcopy(data) if isinstance(data, dict) else {"value": data}
242
+ if data_config and not verification["chart_data_loaded"]:
243
+ data_payload["config"] = deepcopy(data_config)
203
244
  return {
204
245
  "profile": profile,
205
246
  "ws_id": session_profile.selected_ws_id,
206
247
  "ok": True,
207
- "warnings": [],
208
- "verification": {
209
- "chart_exists": True,
210
- "chart_data_loaded": True,
211
- },
212
- "data": {
213
- "chart_id": chart_id,
214
- "chart_name": str(base.get("chartName") or base.get("name") or chart_id).strip() or chart_id,
215
- "chart_type": str(base.get("chartType") or data_config.get("chartType") or "").strip() or None,
216
- "data_source_type": str(base.get("dataSourceType") or "").strip() or None,
217
- "data_source_id": str(base.get("dataSourceId") or "").strip() or None,
218
- "data": deepcopy(data) if isinstance(data, dict) else {"value": data},
219
- },
248
+ "warnings": warnings,
249
+ "verification": verification,
250
+ "data": data_payload,
220
251
  }
221
252
 
222
253
  return self._run(profile, runner)
@@ -236,25 +267,54 @@ class ResourceReadTools(ToolBase):
236
267
  if not chart_id:
237
268
  raise_tool_error(QingflowApiError.config_error("chart_id is required"))
238
269
 
239
- def _resolve_app_key_from_form_id(self, *, profile: str, form_id: int | None) -> str | None:
270
+ def _resolve_app_key_from_view_form(self, *, context: Any, view_key: str) -> str | None:
271
+ try:
272
+ form_payload = self.backend.request("GET", context, f"/view/{view_key}/form")
273
+ except QingflowApiError:
274
+ return None
275
+ if not isinstance(form_payload, dict):
276
+ return None
277
+ app_key = str(form_payload.get("appKey") or "").strip()
278
+ return app_key or None
279
+
280
+ def _resolve_app_key_from_form_id(self, *, context: Any, form_id: int | None) -> str | None:
240
281
  """执行内部辅助逻辑。"""
241
282
  if form_id is None:
242
283
  return None
243
284
  try:
244
- payload = self.apps.app_list(profile=profile)
285
+ payload = self.backend.request("GET", context, "/tag/apps")
245
286
  except QingflowApiError:
246
- return None
247
- items = payload.get("items")
248
- if not isinstance(items, list):
249
- return None
250
- for item in items:
251
- if not isinstance(item, dict):
252
- continue
253
- if _coerce_positive_int(item.get("form_id")) != form_id:
254
- continue
255
- app_key = str(item.get("app_key") or "").strip()
256
- if app_key:
257
- return app_key
287
+ payload = None
288
+ app_key = _find_visible_app_key_by_form_id(payload, form_id=form_id)
289
+ if app_key:
290
+ return app_key
291
+ page_num = 1
292
+ page_size = 200
293
+ while page_num <= 20:
294
+ try:
295
+ page = self.backend.request(
296
+ "GET",
297
+ context,
298
+ "/app/item",
299
+ params={"pageNum": page_num, "pageSize": page_size},
300
+ )
301
+ except QingflowApiError:
302
+ return None
303
+ items = page.get("list") if isinstance(page, dict) else []
304
+ if not isinstance(items, list) or not items:
305
+ return None
306
+ for item in items:
307
+ if not isinstance(item, dict):
308
+ continue
309
+ if _coerce_positive_int(item.get("formId") or item.get("form_id")) != form_id:
310
+ continue
311
+ app_key = str(item.get("appKey") or item.get("app_key") or "").strip()
312
+ if app_key:
313
+ return app_key
314
+ total = _coerce_positive_int(page.get("total")) if isinstance(page, dict) else None
315
+ if total is not None and page_num * page_size >= total:
316
+ break
317
+ page_num += 1
258
318
  return None
259
319
 
260
320
 
@@ -419,3 +479,25 @@ def _coerce_positive_int(value: Any) -> int | None:
419
479
  except (TypeError, ValueError):
420
480
  return None
421
481
  return number if number > 0 else None
482
+
483
+
484
+ def _find_visible_app_key_by_form_id(payload: Any, *, form_id: int) -> str | None:
485
+ if isinstance(payload, list):
486
+ for item in payload:
487
+ resolved = _find_visible_app_key_by_form_id(item, form_id=form_id)
488
+ if resolved:
489
+ return resolved
490
+ return None
491
+ if not isinstance(payload, dict):
492
+ return None
493
+ candidate_form_id = _coerce_positive_int(payload.get("formId") or payload.get("form_id"))
494
+ if candidate_form_id == form_id:
495
+ app_key = str(payload.get("appKey") or payload.get("app_key") or "").strip()
496
+ if app_key:
497
+ return app_key
498
+ for value in payload.values():
499
+ if isinstance(value, (list, dict)):
500
+ resolved = _find_visible_app_key_by_form_id(value, form_id=form_id)
501
+ if resolved:
502
+ return resolved
503
+ return None
@@ -54,6 +54,7 @@ class TaskContextTools(ToolBase):
54
54
  self._task_tools = TaskTools(sessions, backend)
55
55
  self._approval_tools = ApprovalTools(sessions, backend)
56
56
  self._record_tools = RecordTools(sessions, backend)
57
+ self._app_name_cache: dict[str, str | None] = {}
57
58
 
58
59
  def register(self, mcp: FastMCP) -> None:
59
60
  """注册当前工具到 MCP 服务。"""
@@ -1586,7 +1587,7 @@ class TaskContextTools(ToolBase):
1586
1587
  f"/app/{app_key}/apply/{record_id}",
1587
1588
  params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
1588
1589
  )
1589
- app_name = self._task_app_name(detail, node_info)
1590
+ app_name = self._task_app_name(context=context, app_key=app_key, detail=detail, node_info=node_info)
1590
1591
  associated_report_visible = self._resolve_associated_report_visible(node_info, detail)
1591
1592
  associated_reports = {"visible": associated_report_visible, "loaded": False, "count": 0, "items": []}
1592
1593
  if include_associated_reports and associated_report_visible:
@@ -1833,12 +1834,82 @@ class TaskContextTools(ToolBase):
1833
1834
  }
1834
1835
  return {key: value for key, value in compact.items() if value not in (None, "", [])} or None
1835
1836
 
1836
- def _task_app_name(self, detail: dict[str, Any], node_info: dict[str, Any]) -> Any:
1837
+ def _task_app_name(
1838
+ self,
1839
+ *,
1840
+ context: BackendRequestContext,
1841
+ app_key: str,
1842
+ detail: dict[str, Any],
1843
+ node_info: dict[str, Any],
1844
+ ) -> Any:
1837
1845
  for source in (detail, node_info):
1838
1846
  for key in ("formTitle", "appName", "worksheetName", "appTitle"):
1839
1847
  value = source.get(key)
1840
1848
  if value not in (None, ""):
1849
+ if app_key:
1850
+ self._app_name_cache[app_key] = str(value)
1851
+ return value
1852
+ normalized_app_key = str(app_key or "").strip()
1853
+ if not normalized_app_key:
1854
+ return None
1855
+ if normalized_app_key in self._app_name_cache:
1856
+ return self._app_name_cache[normalized_app_key]
1857
+ resolved = self._resolve_task_app_name_from_base_info(context=context, app_key=normalized_app_key)
1858
+ if resolved is None:
1859
+ resolved = self._resolve_task_app_name_from_visible_apps(context=context, app_key=normalized_app_key)
1860
+ self._app_name_cache[normalized_app_key] = resolved
1861
+ return resolved
1862
+
1863
+ def _resolve_task_app_name_from_base_info(
1864
+ self,
1865
+ *,
1866
+ context: BackendRequestContext,
1867
+ app_key: str,
1868
+ ) -> str | None:
1869
+ try:
1870
+ base_info = self.backend.request("GET", context, f"/app/{app_key}/baseInfo")
1871
+ except QingflowApiError:
1872
+ return None
1873
+ if not isinstance(base_info, dict):
1874
+ return None
1875
+ for key in ("formTitle", "title", "appName", "name"):
1876
+ value = str(base_info.get(key) or "").strip()
1877
+ if value:
1878
+ return value
1879
+ return None
1880
+
1881
+ def _resolve_task_app_name_from_visible_apps(
1882
+ self,
1883
+ *,
1884
+ context: BackendRequestContext,
1885
+ app_key: str,
1886
+ ) -> str | None:
1887
+ try:
1888
+ visible_apps = self.backend.request("GET", context, "/tag/apps")
1889
+ except QingflowApiError:
1890
+ return None
1891
+ return self._find_task_app_name_in_visible_apps(visible_apps, app_key=app_key)
1892
+
1893
+ def _find_task_app_name_in_visible_apps(self, payload: Any, *, app_key: str) -> str | None:
1894
+ if isinstance(payload, list):
1895
+ for item in payload:
1896
+ resolved = self._find_task_app_name_in_visible_apps(item, app_key=app_key)
1897
+ if resolved:
1898
+ return resolved
1899
+ return None
1900
+ if not isinstance(payload, dict):
1901
+ return None
1902
+ candidate_app_key = str(payload.get("appKey") or payload.get("app_key") or "").strip()
1903
+ if candidate_app_key == app_key:
1904
+ for key in ("formTitle", "title", "appName", "name"):
1905
+ value = str(payload.get(key) or "").strip()
1906
+ if value:
1841
1907
  return value
1908
+ for value in payload.values():
1909
+ if isinstance(value, (list, dict)):
1910
+ resolved = self._find_task_app_name_in_visible_apps(value, app_key=app_key)
1911
+ if resolved:
1912
+ return resolved
1842
1913
  return None
1843
1914
 
1844
1915
  def _task_record_core_fields(self, answers: Any, *, limit: int = 12) -> dict[str, Any]:
@@ -45,6 +45,16 @@ class WorkspaceTools(ToolBase):
45
45
  ws_id=ws_id if ws_id > 0 else None,
46
46
  )
47
47
 
48
+ @mcp.tool()
49
+ def workspace_select(
50
+ profile: str = DEFAULT_PROFILE,
51
+ ws_id: int = 0,
52
+ ) -> dict[str, Any]:
53
+ return self.workspace_select(
54
+ profile=profile,
55
+ ws_id=ws_id,
56
+ )
57
+
48
58
  @mcp.tool()
49
59
  def workspace_set_plugin_status(
50
60
  profile: str = DEFAULT_PROFILE,
@@ -128,6 +138,43 @@ class WorkspaceTools(ToolBase):
128
138
 
129
139
  return self._run(profile, runner, require_workspace=False)
130
140
 
141
+ @tool_cn_name("切换工作区")
142
+ def workspace_select(
143
+ self,
144
+ *,
145
+ profile: str = DEFAULT_PROFILE,
146
+ ws_id: int,
147
+ ) -> dict[str, Any]:
148
+ """切换当前 profile 选中的工作区,并尽量同步真实 systemVersion。"""
149
+ if ws_id <= 0:
150
+ raise_tool_error(QingflowApiError.config_error("ws_id must be positive"))
151
+
152
+ def runner(_, context):
153
+ workspace = self._fetch_workspace_with_fallback(context, ws_id=ws_id)
154
+ workspace_name = str(workspace.get("workspaceName") or workspace.get("wsName") or workspace.get("remark") or "").strip() or None
155
+ selected = self.sessions.select_workspace(profile, ws_id=ws_id, ws_name=workspace_name)
156
+ system_version = self._workspace_system_version(workspace)
157
+ qf_version_source = "workspace_system_version" if system_version else "unverified"
158
+ if system_version:
159
+ selected = self.sessions.update_route(
160
+ profile,
161
+ qf_version=system_version,
162
+ qf_version_source=qf_version_source,
163
+ )
164
+ return {
165
+ "profile": profile,
166
+ "ws_id": ws_id,
167
+ "qf_version": selected.qf_version,
168
+ "qf_version_source": selected.qf_version_source or qf_version_source,
169
+ "workspace": workspace,
170
+ "selected": {
171
+ "ws_id": selected.selected_ws_id,
172
+ "workspace_name": selected.selected_ws_name,
173
+ },
174
+ }
175
+
176
+ return self._run(profile, runner, require_workspace=False)
177
+
131
178
  @tool_cn_name("设置工作区插件状态")
132
179
  def workspace_set_plugin_status(
133
180
  self,