@josephyan/qingflow-app-user-mcp 0.2.0-beta.982 → 0.2.0-beta.984
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 +100 -18
- package/src/qingflow_mcp/cli/formatters.py +1 -0
- package/src/qingflow_mcp/session_store.py +69 -15
- package/src/qingflow_mcp/tools/ai_builder_tools.py +3 -1
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.984
|
|
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.984 qingflow-app-user-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -2786,6 +2786,19 @@ class AiBuilderFacade:
|
|
|
2786
2786
|
"can_copy_app": _coerce_optional_bool(base.get("copyAppStatus")),
|
|
2787
2787
|
}
|
|
2788
2788
|
|
|
2789
|
+
def _derive_can_edit_app_base(self, *, profile: str, permission_summary: JSONObject) -> bool:
|
|
2790
|
+
if permission_summary.get("can_edit_app") is not True:
|
|
2791
|
+
return False
|
|
2792
|
+
tag_ids = _coerce_int_list(permission_summary.get("tag_ids"))
|
|
2793
|
+
for tag_id in tag_ids:
|
|
2794
|
+
try:
|
|
2795
|
+
package_permission = self._read_package_permission_summary(profile=profile, tag_id=tag_id)
|
|
2796
|
+
except (QingflowApiError, RuntimeError):
|
|
2797
|
+
return False
|
|
2798
|
+
if package_permission.get("can_edit_tag") is not True:
|
|
2799
|
+
return False
|
|
2800
|
+
return True
|
|
2801
|
+
|
|
2789
2802
|
def _read_portal_permission_summary(self, *, dash_key: str, portal_result: dict[str, Any]) -> JSONObject:
|
|
2790
2803
|
tag_ids = _coerce_int_list(portal_result.get("tagIds"))
|
|
2791
2804
|
if not tag_ids:
|
|
@@ -2999,7 +3012,7 @@ class AiBuilderFacade:
|
|
|
2999
3012
|
|
|
3000
3013
|
def app_read_summary(self, *, profile: str, app_key: str) -> JSONObject:
|
|
3001
3014
|
try:
|
|
3002
|
-
|
|
3015
|
+
base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True)
|
|
3003
3016
|
except (QingflowApiError, RuntimeError) as error:
|
|
3004
3017
|
api_error = _coerce_api_error(error)
|
|
3005
3018
|
return _failed_from_api_error(
|
|
@@ -3009,26 +3022,55 @@ class AiBuilderFacade:
|
|
|
3009
3022
|
details={"app_key": app_key},
|
|
3010
3023
|
suggested_next_call={"tool_name": "app_resolve", "arguments": {"profile": profile, "app_key": app_key}},
|
|
3011
3024
|
)
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3025
|
+
base_result = base.get("result") if isinstance(base.get("result"), dict) else {}
|
|
3026
|
+
schema_unavailable = False
|
|
3027
|
+
try:
|
|
3028
|
+
schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
|
|
3029
|
+
parsed = _parse_schema(schema_result)
|
|
3030
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
3031
|
+
api_error = _coerce_api_error(error)
|
|
3032
|
+
if api_error.http_status == 404 or _is_permission_restricted_api_error(api_error):
|
|
3033
|
+
schema_unavailable = True
|
|
3034
|
+
parsed = {"fields": [], "layout": {"sections": []}}
|
|
3035
|
+
else:
|
|
3036
|
+
return _failed_from_api_error(
|
|
3037
|
+
"APP_READ_FAILED",
|
|
3038
|
+
api_error,
|
|
3039
|
+
normalized_args={"app_key": app_key},
|
|
3040
|
+
details={"app_key": app_key},
|
|
3041
|
+
suggested_next_call={"tool_name": "app_resolve", "arguments": {"profile": profile, "app_key": app_key}},
|
|
3042
|
+
)
|
|
3043
|
+
views, views_unavailable = self._load_views_result(
|
|
3044
|
+
profile=profile,
|
|
3045
|
+
app_key=app_key,
|
|
3046
|
+
tolerate_404=True,
|
|
3047
|
+
tolerate_permission_restricted=True,
|
|
3048
|
+
)
|
|
3049
|
+
workflow, workflow_unavailable = self._load_workflow_result(
|
|
3050
|
+
profile=profile,
|
|
3051
|
+
app_key=app_key,
|
|
3052
|
+
tolerate_404=True,
|
|
3053
|
+
tolerate_permission_restricted=True,
|
|
3054
|
+
)
|
|
3015
3055
|
verification_hints = _build_verification_hints(
|
|
3016
|
-
tag_ids=_coerce_int_list(
|
|
3056
|
+
tag_ids=_coerce_int_list(base_result.get("tagIds")),
|
|
3017
3057
|
fields=parsed["fields"],
|
|
3018
3058
|
layout=parsed["layout"],
|
|
3019
3059
|
views=_summarize_views(views),
|
|
3020
3060
|
)
|
|
3061
|
+
if schema_unavailable:
|
|
3062
|
+
verification_hints.append("schema_read_unavailable")
|
|
3021
3063
|
if views_unavailable:
|
|
3022
3064
|
verification_hints.append("views_read_unavailable")
|
|
3023
3065
|
if workflow_unavailable:
|
|
3024
3066
|
verification_hints.append("workflow_read_unavailable")
|
|
3025
3067
|
response = AppReadSummaryResponse(
|
|
3026
3068
|
app_key=app_key,
|
|
3027
|
-
title=
|
|
3028
|
-
app_icon=str(
|
|
3029
|
-
visibility=_public_visibility_from_member_auth(
|
|
3030
|
-
tag_ids=_coerce_int_list(
|
|
3031
|
-
publish_status=
|
|
3069
|
+
title=base_result.get("formTitle"),
|
|
3070
|
+
app_icon=str(base_result.get("appIcon") or "").strip() or None,
|
|
3071
|
+
visibility=_public_visibility_from_member_auth(base_result.get("auth")),
|
|
3072
|
+
tag_ids=_coerce_int_list(base_result.get("tagIds")),
|
|
3073
|
+
publish_status=base_result.get("appPublishStatus"),
|
|
3032
3074
|
field_count=len(parsed["fields"]),
|
|
3033
3075
|
layout_section_count=len(parsed["layout"].get("sections", [])),
|
|
3034
3076
|
view_count=len(_summarize_views(views)),
|
|
@@ -3050,10 +3092,11 @@ class AiBuilderFacade:
|
|
|
3050
3092
|
"warnings": _warnings_from_verification_hints(verification_hints),
|
|
3051
3093
|
"verification": {
|
|
3052
3094
|
"app_exists": True,
|
|
3095
|
+
"schema_read_unavailable": schema_unavailable,
|
|
3053
3096
|
"views_read_unavailable": views_unavailable,
|
|
3054
3097
|
"workflow_read_unavailable": workflow_unavailable,
|
|
3055
3098
|
},
|
|
3056
|
-
"verified": not views_unavailable and not workflow_unavailable,
|
|
3099
|
+
"verified": not schema_unavailable and not views_unavailable and not workflow_unavailable,
|
|
3057
3100
|
**response.model_dump(mode="json"),
|
|
3058
3101
|
}
|
|
3059
3102
|
|
|
@@ -3066,8 +3109,9 @@ class AiBuilderFacade:
|
|
|
3066
3109
|
permission_summary = self._read_app_permission_summary(profile=profile, app_key=app_key)
|
|
3067
3110
|
result["message"] = "read app config summary"
|
|
3068
3111
|
result["editability"] = {
|
|
3112
|
+
"can_edit_app_base": self._derive_can_edit_app_base(profile=profile, permission_summary=permission_summary),
|
|
3069
3113
|
"can_edit_form": permission_summary.get("can_edit_app"),
|
|
3070
|
-
"can_edit_flow": permission_summary.get("
|
|
3114
|
+
"can_edit_flow": permission_summary.get("can_manage_data"),
|
|
3071
3115
|
"can_edit_views": permission_summary.get("can_manage_data"),
|
|
3072
3116
|
"can_edit_charts": permission_summary.get("can_manage_data"),
|
|
3073
3117
|
}
|
|
@@ -7444,17 +7488,35 @@ class AiBuilderFacade:
|
|
|
7444
7488
|
sync_result = {**sync_result, "button_config_restored": True}
|
|
7445
7489
|
return sync_result
|
|
7446
7490
|
|
|
7447
|
-
def _load_views_result(
|
|
7491
|
+
def _load_views_result(
|
|
7492
|
+
self,
|
|
7493
|
+
*,
|
|
7494
|
+
profile: str,
|
|
7495
|
+
app_key: str,
|
|
7496
|
+
tolerate_404: bool,
|
|
7497
|
+
tolerate_permission_restricted: bool = False,
|
|
7498
|
+
) -> tuple[Any, bool]:
|
|
7448
7499
|
try:
|
|
7449
7500
|
views = self.views.view_list_flat(profile=profile, app_key=app_key)
|
|
7450
7501
|
except (QingflowApiError, RuntimeError) as error:
|
|
7451
7502
|
api_error = _coerce_api_error(error)
|
|
7452
|
-
if api_error.http_status == 404
|
|
7503
|
+
if api_error.http_status == 404 or (
|
|
7504
|
+
tolerate_permission_restricted and _is_permission_restricted_api_error(api_error)
|
|
7505
|
+
):
|
|
7453
7506
|
try:
|
|
7454
7507
|
legacy_views = self.views.view_list(profile=profile, app_key=app_key)
|
|
7455
7508
|
except (QingflowApiError, RuntimeError) as legacy_error:
|
|
7456
7509
|
legacy_api_error = _coerce_api_error(legacy_error)
|
|
7457
|
-
if
|
|
7510
|
+
if (
|
|
7511
|
+
tolerate_404
|
|
7512
|
+
and (
|
|
7513
|
+
legacy_api_error.http_status == 404
|
|
7514
|
+
or (
|
|
7515
|
+
tolerate_permission_restricted
|
|
7516
|
+
and _is_permission_restricted_api_error(legacy_api_error)
|
|
7517
|
+
)
|
|
7518
|
+
)
|
|
7519
|
+
):
|
|
7458
7520
|
return [], True
|
|
7459
7521
|
raise
|
|
7460
7522
|
legacy_result = legacy_views.get("result")
|
|
@@ -7471,19 +7533,38 @@ class AiBuilderFacade:
|
|
|
7471
7533
|
legacy_views = self.views.view_list(profile=profile, app_key=app_key)
|
|
7472
7534
|
except (QingflowApiError, RuntimeError) as legacy_error:
|
|
7473
7535
|
legacy_api_error = _coerce_api_error(legacy_error)
|
|
7474
|
-
if
|
|
7536
|
+
if (
|
|
7537
|
+
tolerate_404
|
|
7538
|
+
and (
|
|
7539
|
+
legacy_api_error.http_status == 404
|
|
7540
|
+
or (
|
|
7541
|
+
tolerate_permission_restricted
|
|
7542
|
+
and _is_permission_restricted_api_error(legacy_api_error)
|
|
7543
|
+
)
|
|
7544
|
+
)
|
|
7545
|
+
):
|
|
7475
7546
|
return normalized_views, False
|
|
7476
7547
|
raise
|
|
7477
7548
|
legacy_result = legacy_views.get("result")
|
|
7478
7549
|
legacy_normalized = _normalize_view_collection(legacy_result)
|
|
7479
7550
|
return legacy_normalized or normalized_views, False
|
|
7480
7551
|
|
|
7481
|
-
def _load_workflow_result(
|
|
7552
|
+
def _load_workflow_result(
|
|
7553
|
+
self,
|
|
7554
|
+
*,
|
|
7555
|
+
profile: str,
|
|
7556
|
+
app_key: str,
|
|
7557
|
+
tolerate_404: bool,
|
|
7558
|
+
tolerate_permission_restricted: bool = False,
|
|
7559
|
+
) -> tuple[Any, bool]:
|
|
7482
7560
|
try:
|
|
7483
7561
|
workflow = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key)
|
|
7484
7562
|
except (QingflowApiError, RuntimeError) as error:
|
|
7485
7563
|
api_error = _coerce_api_error(error)
|
|
7486
|
-
if tolerate_404 and
|
|
7564
|
+
if tolerate_404 and (
|
|
7565
|
+
api_error.http_status == 404
|
|
7566
|
+
or (tolerate_permission_restricted and _is_permission_restricted_api_error(api_error))
|
|
7567
|
+
):
|
|
7487
7568
|
return [], True
|
|
7488
7569
|
raise
|
|
7489
7570
|
return workflow.get("result"), False
|
|
@@ -12241,6 +12322,7 @@ def _warnings_from_verification_hints(hints: list[str]) -> list[dict[str, Any]]:
|
|
|
12241
12322
|
"package attachment not verified": _warning("PACKAGE_ATTACHMENT_UNVERIFIED", "package attachment is not verified"),
|
|
12242
12323
|
"layout has unplaced fields": _warning("LAYOUT_HAS_UNPLACED_FIELDS", "layout still contains unplaced fields"),
|
|
12243
12324
|
"no public views detected": _warning("NO_PUBLIC_VIEWS", "no public views were detected"),
|
|
12325
|
+
"schema_read_unavailable": _warning("SCHEMA_READ_UNAVAILABLE", "schema summary readback is unavailable"),
|
|
12244
12326
|
"views_read_unavailable": _warning("VIEWS_READ_UNAVAILABLE", "views summary readback is unavailable"),
|
|
12245
12327
|
"workflow_read_unavailable": _warning("WORKFLOW_READ_UNAVAILABLE", "workflow summary readback is unavailable"),
|
|
12246
12328
|
}
|
|
@@ -118,6 +118,7 @@ def _format_app_get(result: dict[str, Any]) -> str:
|
|
|
118
118
|
if editability:
|
|
119
119
|
lines.append(
|
|
120
120
|
"Editability: "
|
|
121
|
+
f"app_base={editability.get('can_edit_app_base')} / "
|
|
121
122
|
f"form={editability.get('can_edit_form')} / "
|
|
122
123
|
f"flow={editability.get('can_edit_flow')} / "
|
|
123
124
|
f"views={editability.get('can_edit_views')} / "
|
|
@@ -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
|
|
@@ -3058,7 +3058,9 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
|
|
|
3058
3058
|
"execution_notes": [
|
|
3059
3059
|
"returns builder-side app configuration summary and editability",
|
|
3060
3060
|
"use this as the default builder discovery read before fields/layout/views/flow/charts detail reads",
|
|
3061
|
-
"editability
|
|
3061
|
+
"editability is route-aware builder capability summary, not end-user data visibility",
|
|
3062
|
+
"can_edit_app_base covers app base-info writes such as app_name, icon, and visibility",
|
|
3063
|
+
"can_edit_form covers form/schema routes only and does not imply app base-info writes",
|
|
3062
3064
|
"returns normalized app visibility when backend auth is readable",
|
|
3063
3065
|
],
|
|
3064
3066
|
"minimal_example": {
|