@josephyan/qingflow-app-user-mcp 0.2.0-beta.996 → 0.2.0-beta.998

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.996
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.998
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.996 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.998 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.996",
3
+ "version": "0.2.0-beta.998",
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.0b996"
7
+ version = "0.2.0b998"
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.0b996"
8
+ _FALLBACK_VERSION = "0.2.0b998"
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,
@@ -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
+ )
@@ -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 服务。"""
@@ -1147,53 +1148,61 @@ class TaskContextTools(ToolBase):
1147
1148
  "reported_total": matched_total,
1148
1149
  }
1149
1150
 
1150
- def _resolve_todo_task_locator(self, *, profile: str, task_id: Any) -> dict[str, Any]:
1151
+ def _resolve_task_locator_by_task_id(self, *, profile: str, task_id: Any) -> dict[str, Any]:
1151
1152
  task_id_text = normalize_positive_id_text(task_id, field_name="task_id")
1152
- page = 1
1153
+ searched_task_boxes = ("todo", "initiated", "cc", "done")
1154
+ incomplete_task_boxes: list[str] = []
1153
1155
  page_size = 100
1154
- page_amount: int | None = None
1155
- while True:
1156
- response = self._list_normalized_task_items(
1157
- profile=profile,
1158
- task_box="todo",
1159
- flow_status="all",
1160
- app_key=None,
1161
- workflow_node_id=None,
1162
- query=None,
1163
- page=page,
1164
- page_size=page_size,
1156
+ for task_box in searched_task_boxes:
1157
+ page = 1
1158
+ page_amount: int | None = None
1159
+ while True:
1160
+ response = self._list_normalized_task_items(
1161
+ profile=profile,
1162
+ task_box=task_box,
1163
+ flow_status="all",
1164
+ app_key=None,
1165
+ workflow_node_id=None,
1166
+ query=None,
1167
+ page=page,
1168
+ page_size=page_size,
1169
+ )
1170
+ items = response.get("items") if isinstance(response.get("items"), list) else []
1171
+ for item in items:
1172
+ if not isinstance(item, dict) or not ids_equal(item.get("task_id"), task_id_text):
1173
+ continue
1174
+ app_key = str(item.get("app_key") or "").strip()
1175
+ record_id = stringify_backend_id(item.get("record_id"))
1176
+ workflow_node_id = int(item.get("workflow_node_id") or 0)
1177
+ if not app_key or record_id is None or workflow_node_id <= 0:
1178
+ incomplete_task_boxes.append(task_box)
1179
+ continue
1180
+ return {
1181
+ "task_id": task_id_text,
1182
+ "task_box": task_box,
1183
+ "app_key": app_key,
1184
+ "record_id": record_id,
1185
+ "workflow_node_id": workflow_node_id,
1186
+ }
1187
+ if page_amount is None:
1188
+ coerced_page_amount = _coerce_count(response.get("page_amount"))
1189
+ if coerced_page_amount is not None and coerced_page_amount > 0:
1190
+ page_amount = coerced_page_amount
1191
+ if page_amount is not None and page >= page_amount:
1192
+ break
1193
+ if not items or len(items) < page_size:
1194
+ break
1195
+ page += 1
1196
+ if incomplete_task_boxes:
1197
+ searched = ", ".join(incomplete_task_boxes)
1198
+ raise_tool_error(
1199
+ QingflowApiError.config_error(
1200
+ f"task_id={task_id_text} resolved to an incomplete task locator in task_box={searched}; please refresh the task list and retry"
1201
+ )
1165
1202
  )
1166
- items = response.get("items") if isinstance(response.get("items"), list) else []
1167
- for item in items:
1168
- if not isinstance(item, dict) or not ids_equal(item.get("task_id"), task_id_text):
1169
- continue
1170
- app_key = str(item.get("app_key") or "").strip()
1171
- record_id = stringify_backend_id(item.get("record_id"))
1172
- workflow_node_id = int(item.get("workflow_node_id") or 0)
1173
- if not app_key or record_id is None or workflow_node_id <= 0:
1174
- raise_tool_error(
1175
- QingflowApiError.config_error(
1176
- f"task_id={task_id_text} resolved to an incomplete task locator; please refresh the todo list and retry"
1177
- )
1178
- )
1179
- return {
1180
- "task_id": task_id_text,
1181
- "app_key": app_key,
1182
- "record_id": record_id,
1183
- "workflow_node_id": workflow_node_id,
1184
- }
1185
- if page_amount is None:
1186
- coerced_page_amount = _coerce_count(response.get("page_amount"))
1187
- if coerced_page_amount is not None and coerced_page_amount > 0:
1188
- page_amount = coerced_page_amount
1189
- if page_amount is not None and page >= page_amount:
1190
- break
1191
- if not items or len(items) < page_size:
1192
- break
1193
- page += 1
1194
1203
  raise_tool_error(
1195
1204
  QingflowApiError.config_error(
1196
- f"task_id={task_id_text} was not found in the current todo list; task context tools currently resolve actionable todo tasks by task_id"
1205
+ f"task_id={task_id_text} was not found in the current visible task boxes (todo, initiated, cc, done)"
1197
1206
  )
1198
1207
  )
1199
1208
 
@@ -1211,7 +1220,7 @@ class TaskContextTools(ToolBase):
1211
1220
  resolved_record_id: int
1212
1221
  resolved_workflow_node_id: int
1213
1222
  if task_id_text is not None:
1214
- locator = self._resolve_todo_task_locator(profile=profile, task_id=task_id_text)
1223
+ locator = self._resolve_task_locator_by_task_id(profile=profile, task_id=task_id_text)
1215
1224
  resolved_app_key = str(locator["app_key"])
1216
1225
  resolved_record_id = normalize_positive_id_int(locator["record_id"], field_name="record_id")
1217
1226
  resolved_workflow_node_id = int(locator["workflow_node_id"])
@@ -1586,7 +1595,7 @@ class TaskContextTools(ToolBase):
1586
1595
  f"/app/{app_key}/apply/{record_id}",
1587
1596
  params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
1588
1597
  )
1589
- app_name = self._task_app_name(detail, node_info)
1598
+ app_name = self._task_app_name(context=context, app_key=app_key, detail=detail, node_info=node_info)
1590
1599
  associated_report_visible = self._resolve_associated_report_visible(node_info, detail)
1591
1600
  associated_reports = {"visible": associated_report_visible, "loaded": False, "count": 0, "items": []}
1592
1601
  if include_associated_reports and associated_report_visible:
@@ -1833,12 +1842,82 @@ class TaskContextTools(ToolBase):
1833
1842
  }
1834
1843
  return {key: value for key, value in compact.items() if value not in (None, "", [])} or None
1835
1844
 
1836
- def _task_app_name(self, detail: dict[str, Any], node_info: dict[str, Any]) -> Any:
1845
+ def _task_app_name(
1846
+ self,
1847
+ *,
1848
+ context: BackendRequestContext,
1849
+ app_key: str,
1850
+ detail: dict[str, Any],
1851
+ node_info: dict[str, Any],
1852
+ ) -> Any:
1837
1853
  for source in (detail, node_info):
1838
1854
  for key in ("formTitle", "appName", "worksheetName", "appTitle"):
1839
1855
  value = source.get(key)
1840
1856
  if value not in (None, ""):
1857
+ if app_key:
1858
+ self._app_name_cache[app_key] = str(value)
1859
+ return value
1860
+ normalized_app_key = str(app_key or "").strip()
1861
+ if not normalized_app_key:
1862
+ return None
1863
+ if normalized_app_key in self._app_name_cache:
1864
+ return self._app_name_cache[normalized_app_key]
1865
+ resolved = self._resolve_task_app_name_from_base_info(context=context, app_key=normalized_app_key)
1866
+ if resolved is None:
1867
+ resolved = self._resolve_task_app_name_from_visible_apps(context=context, app_key=normalized_app_key)
1868
+ self._app_name_cache[normalized_app_key] = resolved
1869
+ return resolved
1870
+
1871
+ def _resolve_task_app_name_from_base_info(
1872
+ self,
1873
+ *,
1874
+ context: BackendRequestContext,
1875
+ app_key: str,
1876
+ ) -> str | None:
1877
+ try:
1878
+ base_info = self.backend.request("GET", context, f"/app/{app_key}/baseInfo")
1879
+ except QingflowApiError:
1880
+ return None
1881
+ if not isinstance(base_info, dict):
1882
+ return None
1883
+ for key in ("formTitle", "title", "appName", "name"):
1884
+ value = str(base_info.get(key) or "").strip()
1885
+ if value:
1886
+ return value
1887
+ return None
1888
+
1889
+ def _resolve_task_app_name_from_visible_apps(
1890
+ self,
1891
+ *,
1892
+ context: BackendRequestContext,
1893
+ app_key: str,
1894
+ ) -> str | None:
1895
+ try:
1896
+ visible_apps = self.backend.request("GET", context, "/tag/apps")
1897
+ except QingflowApiError:
1898
+ return None
1899
+ return self._find_task_app_name_in_visible_apps(visible_apps, app_key=app_key)
1900
+
1901
+ def _find_task_app_name_in_visible_apps(self, payload: Any, *, app_key: str) -> str | None:
1902
+ if isinstance(payload, list):
1903
+ for item in payload:
1904
+ resolved = self._find_task_app_name_in_visible_apps(item, app_key=app_key)
1905
+ if resolved:
1906
+ return resolved
1907
+ return None
1908
+ if not isinstance(payload, dict):
1909
+ return None
1910
+ candidate_app_key = str(payload.get("appKey") or payload.get("app_key") or "").strip()
1911
+ if candidate_app_key == app_key:
1912
+ for key in ("formTitle", "title", "appName", "name"):
1913
+ value = str(payload.get(key) or "").strip()
1914
+ if value:
1841
1915
  return value
1916
+ for value in payload.values():
1917
+ if isinstance(value, (list, dict)):
1918
+ resolved = self._find_task_app_name_in_visible_apps(value, app_key=app_key)
1919
+ if resolved:
1920
+ return resolved
1842
1921
  return None
1843
1922
 
1844
1923
  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,