@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 +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/builder_facade/service.py +46 -20
- package/src/qingflow_mcp/cli/commands/task.py +4 -1
- package/src/qingflow_mcp/session_store.py +69 -15
- package/src/qingflow_mcp/tools/task_context_tools.py +133 -5
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.
|
|
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.
|
|
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
package/pyproject.toml
CHANGED
|
@@ -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
|
-
|
|
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
|
-
"
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
),
|
|
769
|
+
"package_name_verified": package_name_verified,
|
|
770
|
+
"package_icon_verified": package_icon_verified,
|
|
771
|
+
"visibility_verified": visibility_verified,
|
|
746
772
|
},
|
|
747
|
-
"verified":
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
|
319
|
+
except OSError:
|
|
273
320
|
return False
|
|
274
321
|
|
|
275
|
-
def
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
284
|
-
|
|
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.
|
|
288
|
-
except
|
|
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":
|
|
186
|
-
"page_amount":
|
|
187
|
-
"reported_total":
|
|
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
|
*,
|