@josephyan/qingflow-app-user-mcp 0.2.0-beta.983 → 0.2.0-beta.985

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.983
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.985
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.983 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.985 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.983",
3
+ "version": "0.2.0-beta.985",
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.0b983"
7
+ version = "0.2.0b985"
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.0b983"
8
+ _FALLBACK_VERSION = "0.2.0b985"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -514,6 +514,8 @@ class AiBuilderFacade:
514
514
  }
515
515
  effective_package_id = _coerce_positive_int(package_id)
516
516
  created = False
517
+ create_result: JSONObject | None = None
518
+ update_result: JSONObject | None = None
517
519
  permission_outcomes: list[PermissionCheckOutcome] = []
518
520
 
519
521
  if effective_package_id is None:
@@ -604,11 +606,36 @@ class AiBuilderFacade:
604
606
  )
605
607
  except VisibilityResolutionError:
606
608
  expected_visibility = None
609
+ metadata_verified = True
610
+ if metadata_requested and update_result is not None:
611
+ metadata_verified = bool(update_result.get("verified"))
612
+ elif created and create_result is not None:
613
+ metadata_verified = bool(create_result.get("verified"))
614
+ layout_verified = True
615
+ if items is not None and layout_result is not None:
616
+ layout_verified = bool(layout_result.get("verified"))
617
+ response_verification: JSONObject = {
618
+ "package_exists": True,
619
+ "package_created": created,
620
+ "layout_applied": items is not None,
621
+ "metadata_verified": metadata_verified,
622
+ "layout_verified": layout_verified,
623
+ "visibility_verified": None
624
+ if expected_visibility is None
625
+ else _visibility_matches_expected(verification.get("visibility"), expected_visibility),
626
+ }
627
+ if isinstance(update_result, dict):
628
+ update_verification = update_result.get("verification")
629
+ if isinstance(update_verification, dict):
630
+ for key in ("package_name_verified", "package_icon_verified", "visibility_verified"):
631
+ if key in update_verification:
632
+ response_verification[key] = deepcopy(update_verification.get(key))
633
+ response_verified = metadata_verified and layout_verified and response_verification.get("visibility_verified") is not False
607
634
  response: JSONObject = {
608
- "status": "success",
635
+ "status": "success" if response_verified else "partial_success",
609
636
  "error_code": None,
610
637
  "recoverable": False,
611
- "message": "applied package",
638
+ "message": "applied package" if response_verified else "applied package with unverified readback",
612
639
  "normalized_args": normalized_args,
613
640
  "missing_fields": [],
614
641
  "allowed_values": {},
@@ -617,15 +644,8 @@ class AiBuilderFacade:
617
644
  "suggested_next_call": None,
618
645
  "noop": not (created or metadata_requested or items is not None),
619
646
  "warnings": [],
620
- "verification": {
621
- "package_exists": True,
622
- "package_created": created,
623
- "layout_applied": items is not None,
624
- "visibility_verified": None
625
- if expected_visibility is None
626
- else _visibility_matches_expected(verification.get("visibility"), expected_visibility),
627
- },
628
- "verified": True,
647
+ "verification": response_verification,
648
+ "verified": response_verified,
629
649
  **{
630
650
  key: deepcopy(value)
631
651
  for key, value in verification.items()
@@ -683,7 +703,7 @@ class AiBuilderFacade:
683
703
  )
684
704
  raw_current = current.get("result") if isinstance(current.get("result"), dict) else {}
685
705
  raw_current_base = current_base.get("result") if isinstance(current_base.get("result"), dict) else {}
686
- current_name = str(raw_current.get("tagName") or "").strip() or None
706
+ current_name = str(raw_current.get("tagName") or raw_current_base.get("tagName") or "").strip() or None
687
707
  desired_name = str(package_name or current_name or "").strip() or current_name or "未命名应用包"
688
708
  desired_icon = encode_workspace_icon_with_defaults(
689
709
  icon=icon,
@@ -724,27 +744,33 @@ class AiBuilderFacade:
724
744
  verification = self.package_get(profile=profile, package_id=tag_id)
725
745
  if verification.get("status") != "success":
726
746
  return verification
747
+ package_name_verified = str(verification.get("package_name") or "").strip() == desired_name
748
+ package_icon_verified = str(verification.get("icon") or "").strip() == desired_icon
749
+ visibility_verified = _visibility_matches_expected(
750
+ verification.get("visibility"),
751
+ _public_visibility_from_member_auth(desired_auth),
752
+ )
753
+ verified = package_name_verified and package_icon_verified and visibility_verified
727
754
  return {
728
- "status": "success",
755
+ "status": "success" if verified else "partial_success",
729
756
  "error_code": None,
730
757
  "recoverable": False,
731
- "message": "updated package",
758
+ "message": "updated package" if verified else "updated package with unverified readback",
732
759
  "normalized_args": normalized_args,
733
760
  "missing_fields": [],
734
761
  "allowed_values": {},
735
762
  "details": {},
736
763
  "request_id": update_result.get("request_id") if isinstance(update_result, dict) else None,
737
- "suggested_next_call": None,
764
+ "suggested_next_call": None if verified else {"tool_name": "package_get", "arguments": {"profile": profile, "package_id": tag_id}},
738
765
  "noop": False,
739
766
  "warnings": [],
740
767
  "verification": {
741
768
  "package_exists": True,
742
- "visibility_verified": _visibility_matches_expected(
743
- verification.get("visibility"),
744
- _public_visibility_from_member_auth(desired_auth),
745
- ),
769
+ "package_name_verified": package_name_verified,
770
+ "package_icon_verified": package_icon_verified,
771
+ "visibility_verified": visibility_verified,
746
772
  },
747
- "verified": True,
773
+ "verified": verified,
748
774
  **{
749
775
  key: deepcopy(value)
750
776
  for key, value in verification.items()
@@ -15,7 +15,10 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
15
15
  list_parser.add_argument("--flow-status", default="all")
16
16
  list_parser.add_argument("--app-key")
17
17
  list_parser.add_argument("--workflow-node-id", type=int)
18
- list_parser.add_argument("--query")
18
+ list_parser.add_argument(
19
+ "--query",
20
+ help="先走后端待办检索;当后端返回零结果时,公开 task_list 会回退到本地匹配 app_name / workflow_node_name / app_key / record_id。",
21
+ )
19
22
  list_parser.add_argument("--page", type=int, default=1)
20
23
  list_parser.add_argument("--page-size", type=int, default=20)
21
24
  list_parser.set_defaults(handler=_handle_list, format_hint="task_list")
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import os
4
5
  from dataclasses import asdict, dataclass
5
6
  from datetime import datetime, timezone
6
7
  from pathlib import Path
@@ -70,6 +71,7 @@ class SessionStore:
70
71
  profiles_path = get_profiles_path() if base_dir is None else Path(base_dir) / "profiles.json"
71
72
  self._profiles_path = profiles_path
72
73
  self._profiles_path.parent.mkdir(parents=True, exist_ok=True)
74
+ self._secrets_path = self._profiles_path.parent / "secrets.json"
73
75
  self._keyring = keyring_backend if keyring_backend is not None else keyring
74
76
  self._memory_sessions: dict[str, BackendSession] = {}
75
77
  self._logged_out_profiles: set[str] = set()
@@ -264,26 +266,78 @@ class SessionStore:
264
266
  json.dump(payload, handle, ensure_ascii=False, indent=2)
265
267
 
266
268
  def _set_secret(self, key: str, value: str) -> bool:
267
- if self._keyring is None:
268
- return False
269
+ if self._keyring is not None:
270
+ try:
271
+ self._keyring.set_password(KEYRING_SERVICE_NAME, key, value)
272
+ self._delete_file_secret(key)
273
+ return True
274
+ except Exception:
275
+ pass
276
+ return self._set_file_secret(key, value)
277
+
278
+ def _get_secret(self, key: str) -> str | None:
279
+ if self._keyring is not None:
280
+ try:
281
+ value = self._keyring.get_password(KEYRING_SERVICE_NAME, key)
282
+ except Exception:
283
+ value = None
284
+ if value:
285
+ return value
286
+ return self._get_file_secret(key)
287
+
288
+ def _delete_secret(self, key: str) -> None:
289
+ if self._keyring is not None:
290
+ try:
291
+ self._keyring.delete_password(KEYRING_SERVICE_NAME, key)
292
+ except Exception:
293
+ pass
294
+ self._delete_file_secret(key)
295
+
296
+ def _load_file_secrets(self) -> dict[str, str]:
297
+ if not self._secrets_path.exists():
298
+ return {}
299
+ try:
300
+ with self._secrets_path.open("r", encoding="utf-8") as handle:
301
+ payload = json.load(handle)
302
+ except (OSError, json.JSONDecodeError):
303
+ return {}
304
+ if not isinstance(payload, dict):
305
+ return {}
306
+ return {str(key): str(value) for key, value in payload.items() if isinstance(value, str)}
307
+
308
+ def _save_file_secrets(self, payload: dict[str, str]) -> bool:
309
+ self._secrets_path.parent.mkdir(parents=True, exist_ok=True)
269
310
  try:
270
- self._keyring.set_password(KEYRING_SERVICE_NAME, key, value)
311
+ fd = os.open(self._secrets_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
312
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
313
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
314
+ try:
315
+ os.chmod(self._secrets_path, 0o600)
316
+ except OSError:
317
+ pass
271
318
  return True
272
- except Exception:
319
+ except OSError:
273
320
  return False
274
321
 
275
- def _get_secret(self, key: str) -> str | None:
276
- if self._keyring is None:
277
- return None
278
- try:
279
- return self._keyring.get_password(KEYRING_SERVICE_NAME, key)
280
- except Exception:
281
- return None
322
+ def _set_file_secret(self, key: str, value: str) -> bool:
323
+ payload = self._load_file_secrets()
324
+ payload[key] = value
325
+ return self._save_file_secrets(payload)
282
326
 
283
- def _delete_secret(self, key: str) -> None:
284
- if self._keyring is None:
327
+ def _get_file_secret(self, key: str) -> str | None:
328
+ return self._load_file_secrets().get(key)
329
+
330
+ def _delete_file_secret(self, key: str) -> None:
331
+ payload = self._load_file_secrets()
332
+ if key not in payload:
333
+ return
334
+ payload.pop(key, None)
335
+ if payload:
336
+ self._save_file_secrets(payload)
285
337
  return
286
338
  try:
287
- self._keyring.delete_password(KEYRING_SERVICE_NAME, key)
288
- except Exception:
339
+ self._secrets_path.unlink()
340
+ except FileNotFoundError:
341
+ return
342
+ except OSError:
289
343
  return
@@ -45,7 +45,12 @@ class TaskContextTools(ToolBase):
45
45
  self._record_tools = RecordTools(sessions, backend)
46
46
 
47
47
  def register(self, mcp: FastMCP) -> None:
48
- @mcp.tool()
48
+ @mcp.tool(
49
+ description=(
50
+ "List workflow tasks. `query` first uses backend task search; if the backend returns zero rows, "
51
+ "public task_list falls back to local matching on app_name, workflow_node_name, app_key, and record_id."
52
+ )
53
+ )
49
54
  def task_list(
50
55
  profile: str = DEFAULT_PROFILE,
51
56
  task_box: str = "todo",
@@ -165,26 +170,55 @@ class TaskContextTools(ToolBase):
165
170
  create_time_asc=None,
166
171
  )
167
172
  task_page = raw.get("page", {})
173
+ warnings: list[dict[str, Any]] = []
168
174
  items = [
169
175
  self._normalize_task_item(item, task_box=task_box, flow_status=flow_status)
170
176
  for item in _task_page_items(task_page)
171
177
  if isinstance(item, dict)
172
178
  ]
179
+ returned_items = len(items)
180
+ page_amount = _task_page_amount(task_page)
181
+ reported_total = _task_page_total(task_page)
182
+ if query and not items:
183
+ fallback = self._task_list_local_query_fallback(
184
+ profile=profile,
185
+ task_box=task_box,
186
+ flow_status=flow_status,
187
+ app_key=app_key,
188
+ workflow_node_id=workflow_node_id,
189
+ query=query,
190
+ page=page,
191
+ page_size=page_size,
192
+ )
193
+ if fallback is not None:
194
+ items = fallback["items"]
195
+ returned_items = len(items)
196
+ page_amount = fallback["page_amount"]
197
+ reported_total = fallback["reported_total"]
198
+ warnings.append(
199
+ {
200
+ "code": "TASK_LIST_QUERY_FALLBACK_APPLIED",
201
+ "message": (
202
+ "backend searchKey returned zero tasks; task_list fell back to local matching on "
203
+ "app_name, workflow_node_name, app_key, and record_id."
204
+ ),
205
+ }
206
+ )
173
207
  return {
174
208
  "profile": profile,
175
209
  "ws_id": raw.get("ws_id"),
176
210
  "ok": True,
177
211
  "request_route": raw.get("request_route"),
178
- "warnings": [],
212
+ "warnings": warnings,
179
213
  "output_profile": "normal",
180
214
  "data": {
181
215
  "items": items,
182
216
  "pagination": {
183
217
  "page": page,
184
218
  "page_size": page_size,
185
- "returned_items": len(items),
186
- "page_amount": _task_page_amount(task_page),
187
- "reported_total": _task_page_total(task_page),
219
+ "returned_items": returned_items,
220
+ "page_amount": page_amount,
221
+ "reported_total": reported_total,
188
222
  },
189
223
  "selection": {
190
224
  "task_box": task_box,
@@ -656,6 +690,25 @@ class TaskContextTools(ToolBase):
656
690
  or verification.get("downstream_todo_changed")
657
691
  or verification.get("workflow_log_advanced")
658
692
  )
693
+ record_state_error = verification.get("record_state_error")
694
+ runtime_consumed_after_action = bool(
695
+ runtime_verified
696
+ and isinstance(record_state_error, dict)
697
+ and record_state_error.get("backend_code") == 46001
698
+ )
699
+ if runtime_consumed_after_action:
700
+ verification["record_state_scope"] = "current_node_runtime"
701
+ verification["record_state_unavailable_reason"] = "runtime_consumed_after_action"
702
+ verification["record_state_unavailability_expected"] = True
703
+ warnings.append(
704
+ {
705
+ "code": "TASK_RUNTIME_CONSUMED_AFTER_ACTION",
706
+ "message": (
707
+ "the current workflow node runtime is no longer readable after the action (backend 46001), "
708
+ "which usually means the node has been consumed and the workflow has already continued."
709
+ ),
710
+ }
711
+ )
659
712
  verification["runtime_continuation_verified"] = runtime_verified
660
713
  if not runtime_verified:
661
714
  warnings.append(
@@ -935,6 +988,81 @@ class TaskContextTools(ToolBase):
935
988
  return []
936
989
  return [item for item in items if isinstance(item, dict)]
937
990
 
991
+ def _task_list_local_query_fallback(
992
+ self,
993
+ *,
994
+ profile: str,
995
+ task_box: str,
996
+ flow_status: str,
997
+ app_key: str | None,
998
+ workflow_node_id: int | None,
999
+ query: str,
1000
+ page: int,
1001
+ page_size: int,
1002
+ ) -> dict[str, Any] | None:
1003
+ normalized_type = self._task_tools._task_box_to_type(task_box)
1004
+ normalized_status = self._task_tools._flow_status_to_process_status(flow_status)
1005
+ scan_page_size = max(page_size, 100)
1006
+ scan_page = 1
1007
+ page_amount: int | None = None
1008
+ matched_items: list[dict[str, Any]] = []
1009
+ while True:
1010
+ raw = self._task_tools.task_list(
1011
+ profile=profile,
1012
+ type=normalized_type,
1013
+ process_status=normalized_status,
1014
+ app_key=app_key,
1015
+ node_id=workflow_node_id,
1016
+ search_key=None,
1017
+ page_num=scan_page,
1018
+ page_size=scan_page_size,
1019
+ create_time_asc=None,
1020
+ )
1021
+ task_page = raw.get("page", {})
1022
+ raw_items = _task_page_items(task_page)
1023
+ normalized_items = [
1024
+ self._normalize_task_item(item, task_box=task_box, flow_status=flow_status)
1025
+ for item in raw_items
1026
+ if isinstance(item, dict)
1027
+ ]
1028
+ matched_items.extend(item for item in normalized_items if self._task_item_matches_query(item, query))
1029
+ if page_amount is None:
1030
+ coerced_page_amount = _coerce_count(_task_page_amount(task_page))
1031
+ if coerced_page_amount is not None and coerced_page_amount > 0:
1032
+ page_amount = coerced_page_amount
1033
+ if page_amount is not None and scan_page >= page_amount:
1034
+ break
1035
+ if not raw_items or len(raw_items) < scan_page_size:
1036
+ break
1037
+ scan_page += 1
1038
+ if not matched_items:
1039
+ return None
1040
+ start = max(page - 1, 0) * page_size
1041
+ end = start + page_size
1042
+ matched_total = len(matched_items)
1043
+ matched_page_amount = (matched_total + page_size - 1) // page_size if page_size > 0 else 0
1044
+ return {
1045
+ "items": matched_items[start:end],
1046
+ "page_amount": matched_page_amount,
1047
+ "reported_total": matched_total,
1048
+ }
1049
+
1050
+ def _task_item_matches_query(self, item: dict[str, Any], query: str) -> bool:
1051
+ needle = str(query or "").strip().casefold()
1052
+ if not needle:
1053
+ return False
1054
+ for candidate in (
1055
+ item.get("app_name"),
1056
+ item.get("workflow_node_name"),
1057
+ item.get("app_key"),
1058
+ item.get("record_id"),
1059
+ ):
1060
+ if candidate in (None, ""):
1061
+ continue
1062
+ if needle in str(candidate).casefold():
1063
+ return True
1064
+ return False
1065
+
938
1066
  def task_associated_report_detail_get(
939
1067
  self,
940
1068
  *,