@qingflow-tech/qingflow-app-builder-mcp 1.0.1 → 1.0.3
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/docs/local-agent-install.md +9 -3
- package/npm/lib/runtime.mjs +10 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-builder/SKILL.md +88 -184
- package/skills/qingflow-app-builder/references/create-app.md +15 -34
- package/skills/qingflow-app-builder/references/gotchas.md +3 -3
- package/skills/qingflow-app-builder/references/solution-playbooks.md +1 -2
- package/skills/qingflow-app-builder/references/tool-selection.md +9 -10
- package/src/qingflow_mcp/__init__.py +33 -1
- package/src/qingflow_mcp/builder_facade/models.py +14 -4
- package/src/qingflow_mcp/builder_facade/service.py +1582 -124
- package/src/qingflow_mcp/cli/commands/auth.py +69 -1
- package/src/qingflow_mcp/cli/commands/builder.py +4 -3
- package/src/qingflow_mcp/cli/commands/record.py +5 -5
- package/src/qingflow_mcp/cli/commands/task.py +74 -22
- package/src/qingflow_mcp/cli/commands/workspace.py +22 -0
- package/src/qingflow_mcp/cli/formatters.py +287 -48
- package/src/qingflow_mcp/cli/main.py +6 -1
- package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
- package/src/qingflow_mcp/config.py +8 -0
- package/src/qingflow_mcp/errors.py +2 -2
- package/src/qingflow_mcp/id_utils.py +49 -0
- package/src/qingflow_mcp/public_surface.py +11 -1
- package/src/qingflow_mcp/response_trim.py +380 -9
- package/src/qingflow_mcp/server.py +4 -0
- package/src/qingflow_mcp/server_app_builder.py +11 -1
- package/src/qingflow_mcp/server_app_user.py +24 -0
- package/src/qingflow_mcp/session_store.py +69 -15
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +2 -2
- package/src/qingflow_mcp/solution/executor.py +2 -2
- package/src/qingflow_mcp/tools/ai_builder_tools.py +48 -18
- package/src/qingflow_mcp/tools/app_tools.py +1 -0
- package/src/qingflow_mcp/tools/auth_tools.py +271 -12
- package/src/qingflow_mcp/tools/base.py +6 -2
- package/src/qingflow_mcp/tools/code_block_tools.py +2 -2
- package/src/qingflow_mcp/tools/import_tools.py +36 -2
- package/src/qingflow_mcp/tools/record_tools.py +410 -156
- package/src/qingflow_mcp/tools/resource_read_tools.py +114 -32
- package/src/qingflow_mcp/tools/task_context_tools.py +899 -141
- package/src/qingflow_mcp/tools/workspace_tools.py +141 -0
|
@@ -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
|
|
@@ -77,6 +78,7 @@ class SessionStore:
|
|
|
77
78
|
profiles_path = get_profiles_path() if base_dir is None else Path(base_dir) / "profiles.json"
|
|
78
79
|
self._profiles_path = profiles_path
|
|
79
80
|
self._profiles_path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
self._secrets_path = self._profiles_path.parent / "secrets.json"
|
|
80
82
|
self._keyring = keyring_backend if keyring_backend is not None else keyring
|
|
81
83
|
self._memory_sessions: dict[str, BackendSession] = {}
|
|
82
84
|
self._logged_out_profiles: set[str] = set()
|
|
@@ -290,26 +292,78 @@ class SessionStore:
|
|
|
290
292
|
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
|
291
293
|
|
|
292
294
|
def _set_secret(self, key: str, value: str) -> bool:
|
|
293
|
-
if self._keyring is None:
|
|
294
|
-
|
|
295
|
+
if self._keyring is not None:
|
|
296
|
+
try:
|
|
297
|
+
self._keyring.set_password(KEYRING_SERVICE_NAME, key, value)
|
|
298
|
+
self._delete_file_secret(key)
|
|
299
|
+
return True
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
return self._set_file_secret(key, value)
|
|
303
|
+
|
|
304
|
+
def _get_secret(self, key: str) -> str | None:
|
|
305
|
+
if self._keyring is not None:
|
|
306
|
+
try:
|
|
307
|
+
value = self._keyring.get_password(KEYRING_SERVICE_NAME, key)
|
|
308
|
+
except Exception:
|
|
309
|
+
value = None
|
|
310
|
+
if value:
|
|
311
|
+
return value
|
|
312
|
+
return self._get_file_secret(key)
|
|
313
|
+
|
|
314
|
+
def _delete_secret(self, key: str) -> None:
|
|
315
|
+
if self._keyring is not None:
|
|
316
|
+
try:
|
|
317
|
+
self._keyring.delete_password(KEYRING_SERVICE_NAME, key)
|
|
318
|
+
except Exception:
|
|
319
|
+
pass
|
|
320
|
+
self._delete_file_secret(key)
|
|
321
|
+
|
|
322
|
+
def _load_file_secrets(self) -> dict[str, str]:
|
|
323
|
+
if not self._secrets_path.exists():
|
|
324
|
+
return {}
|
|
325
|
+
try:
|
|
326
|
+
with self._secrets_path.open("r", encoding="utf-8") as handle:
|
|
327
|
+
payload = json.load(handle)
|
|
328
|
+
except (OSError, json.JSONDecodeError):
|
|
329
|
+
return {}
|
|
330
|
+
if not isinstance(payload, dict):
|
|
331
|
+
return {}
|
|
332
|
+
return {str(key): str(value) for key, value in payload.items() if isinstance(value, str)}
|
|
333
|
+
|
|
334
|
+
def _save_file_secrets(self, payload: dict[str, str]) -> bool:
|
|
335
|
+
self._secrets_path.parent.mkdir(parents=True, exist_ok=True)
|
|
295
336
|
try:
|
|
296
|
-
self.
|
|
337
|
+
fd = os.open(self._secrets_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
338
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
339
|
+
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
|
340
|
+
try:
|
|
341
|
+
os.chmod(self._secrets_path, 0o600)
|
|
342
|
+
except OSError:
|
|
343
|
+
pass
|
|
297
344
|
return True
|
|
298
|
-
except
|
|
345
|
+
except OSError:
|
|
299
346
|
return False
|
|
300
347
|
|
|
301
|
-
def
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
return self._keyring.get_password(KEYRING_SERVICE_NAME, key)
|
|
306
|
-
except Exception:
|
|
307
|
-
return None
|
|
348
|
+
def _set_file_secret(self, key: str, value: str) -> bool:
|
|
349
|
+
payload = self._load_file_secrets()
|
|
350
|
+
payload[key] = value
|
|
351
|
+
return self._save_file_secrets(payload)
|
|
308
352
|
|
|
309
|
-
def
|
|
310
|
-
|
|
353
|
+
def _get_file_secret(self, key: str) -> str | None:
|
|
354
|
+
return self._load_file_secrets().get(key)
|
|
355
|
+
|
|
356
|
+
def _delete_file_secret(self, key: str) -> None:
|
|
357
|
+
payload = self._load_file_secrets()
|
|
358
|
+
if key not in payload:
|
|
359
|
+
return
|
|
360
|
+
payload.pop(key, None)
|
|
361
|
+
if payload:
|
|
362
|
+
self._save_file_secrets(payload)
|
|
311
363
|
return
|
|
312
364
|
try:
|
|
313
|
-
self.
|
|
314
|
-
except
|
|
365
|
+
self._secrets_path.unlink()
|
|
366
|
+
except FileNotFoundError:
|
|
367
|
+
return
|
|
368
|
+
except OSError:
|
|
315
369
|
return
|
|
@@ -307,7 +307,7 @@ def build_reference_config(field: dict[str, Any], temp_id: int) -> dict[str, Any
|
|
|
307
307
|
"queId": que_id,
|
|
308
308
|
"queTitle": label,
|
|
309
309
|
"queType": _normalize_reference_que_type(raw_type) or "2",
|
|
310
|
-
"queAuth":
|
|
310
|
+
"queAuth": 3,
|
|
311
311
|
"ordinal": ordinal,
|
|
312
312
|
"quoteId": temp_id,
|
|
313
313
|
"_field_id": field_id,
|
|
@@ -320,7 +320,7 @@ def build_reference_config(field: dict[str, Any], temp_id: int) -> dict[str, Any
|
|
|
320
320
|
auth_ques = []
|
|
321
321
|
for ordinal, field_id in enumerate(auth_field_ids, start=1):
|
|
322
322
|
que_id = auth_field_que_ids[ordinal - 1] if ordinal - 1 < len(auth_field_que_ids) else 0
|
|
323
|
-
auth_ques.append({"queId": que_id, "queAuth":
|
|
323
|
+
auth_ques.append({"queId": que_id, "queAuth": 3, "_field_id": field_id})
|
|
324
324
|
return {
|
|
325
325
|
"referAppKey": "__TARGET_APP_KEY__",
|
|
326
326
|
"referQueId": display_field_que_id,
|
|
@@ -857,12 +857,12 @@ class SolutionExecutor:
|
|
|
857
857
|
if refer_que_id is None:
|
|
858
858
|
continue
|
|
859
859
|
resolved["queId"] = refer_que_id
|
|
860
|
-
resolved["queAuth"] = int(resolved.get("queAuth",
|
|
860
|
+
resolved["queAuth"] = int(resolved.get("queAuth", 3))
|
|
861
861
|
auth_ques.append(resolved)
|
|
862
862
|
if not auth_ques:
|
|
863
863
|
fallback_que_id = target_meta.get("by_field_id", {}).get(target_field_id)
|
|
864
864
|
if fallback_que_id is not None:
|
|
865
|
-
auth_ques.append({"queId": fallback_que_id, "queAuth":
|
|
865
|
+
auth_ques.append({"queId": fallback_que_id, "queAuth": 3})
|
|
866
866
|
reference_config["referAuthQues"] = auth_ques
|
|
867
867
|
reference_config["fieldNameShow"] = bool(reference_config.get("fieldNameShow", True))
|
|
868
868
|
fill_rules = []
|
|
@@ -1873,7 +1873,7 @@ class AiBuilderTools(ToolBase):
|
|
|
1873
1873
|
dash_name: str = "",
|
|
1874
1874
|
package_id: int | None = None,
|
|
1875
1875
|
publish: bool = True,
|
|
1876
|
-
sections: list[JSONObject],
|
|
1876
|
+
sections: list[JSONObject] | None = None,
|
|
1877
1877
|
visibility: JSONObject | None = None,
|
|
1878
1878
|
auth: JSONObject | None = None,
|
|
1879
1879
|
icon: str | None = None,
|
|
@@ -2665,6 +2665,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
|
|
|
2665
2665
|
"field.autoTrigger": "field.auto_trigger",
|
|
2666
2666
|
"field.customBtnTextStatus": "field.custom_button_text_enabled",
|
|
2667
2667
|
"field.customBtnText": "field.custom_button_text",
|
|
2668
|
+
"field.subfieldUpdates": "field.subfield_updates",
|
|
2668
2669
|
},
|
|
2669
2670
|
"allowed_values": {
|
|
2670
2671
|
"field.type": [member.value for member in PublicFieldType],
|
|
@@ -2730,6 +2731,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
|
|
|
2730
2731
|
"field.autoTrigger": "field.auto_trigger",
|
|
2731
2732
|
"field.customBtnTextStatus": "field.custom_button_text_enabled",
|
|
2732
2733
|
"field.customBtnText": "field.custom_button_text",
|
|
2734
|
+
"field.subfieldUpdates": "field.subfield_updates",
|
|
2733
2735
|
},
|
|
2734
2736
|
"allowed_values": {
|
|
2735
2737
|
"field.type": [member.value for member in PublicFieldType],
|
|
@@ -2751,6 +2753,9 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
|
|
|
2751
2753
|
"relation_mode=multiple maps to referenceConfig.optionalDataNum=0",
|
|
2752
2754
|
"relation fields now require both display_field and visible_fields in MCP/CLI payloads",
|
|
2753
2755
|
"if relation target metadata lookup is blocked by 40161/40002/40027, explicit display_field.name and visible_fields[].name let builder degrade verification and still continue schema write",
|
|
2756
|
+
"update_fields[].set.subfield_updates is the safe patch path for editing existing subtable child fields without rebuilding the entire subtable",
|
|
2757
|
+
"subfield_updates only supports safe child overlays: name, required, description, and nested subfield_updates",
|
|
2758
|
+
"set.subfields remains the full replace/rebuild path for a subtable and is higher risk when hidden relation/reference children exist",
|
|
2754
2759
|
"department fields accept department_scope with mode=all or mode=custom; custom scope requires explicit departments[].dept_id and optional include_sub_departs",
|
|
2755
2760
|
"q_linker_binding lets you declare request config, dynamic inputs, alias parsing, and target-field bindings in one step; builder writes remoteLookupConfig plus the existing backend relation-default and questionRelations structures",
|
|
2756
2761
|
"code_block_binding lets you declare inputs, code, alias parsing, and target-field bindings in one step; builder writes codeBlockConfig plus the existing backend relation-default and questionRelations structures",
|
|
@@ -2798,6 +2803,26 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
|
|
|
2798
2803
|
"update_fields": [],
|
|
2799
2804
|
"remove_fields": [],
|
|
2800
2805
|
},
|
|
2806
|
+
"subfield_update_example": {
|
|
2807
|
+
"profile": "default",
|
|
2808
|
+
"app_key": "APP_REWARD",
|
|
2809
|
+
"publish": True,
|
|
2810
|
+
"add_fields": [],
|
|
2811
|
+
"update_fields": [
|
|
2812
|
+
{
|
|
2813
|
+
"selector": {"que_id": 441305044},
|
|
2814
|
+
"set": {
|
|
2815
|
+
"subfield_updates": [
|
|
2816
|
+
{
|
|
2817
|
+
"selector": {"que_id": 441305045},
|
|
2818
|
+
"set": {"name": "景品名"},
|
|
2819
|
+
}
|
|
2820
|
+
]
|
|
2821
|
+
},
|
|
2822
|
+
}
|
|
2823
|
+
],
|
|
2824
|
+
"remove_fields": [],
|
|
2825
|
+
},
|
|
2801
2826
|
"department_scope_example": {
|
|
2802
2827
|
"profile": "default",
|
|
2803
2828
|
"app_key": "APP_LEAD",
|
|
@@ -3149,7 +3174,9 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
|
|
|
3149
3174
|
"execution_notes": [
|
|
3150
3175
|
"returns builder-side app configuration summary and editability",
|
|
3151
3176
|
"use this as the default builder discovery read before fields/layout/views/flow/charts detail reads",
|
|
3152
|
-
"editability
|
|
3177
|
+
"editability is route-aware builder capability summary, not end-user data visibility",
|
|
3178
|
+
"can_edit_app_base covers app base-info writes such as app_name, icon, and visibility",
|
|
3179
|
+
"can_edit_form covers form/schema routes only and does not imply app base-info writes",
|
|
3153
3180
|
"returns normalized app visibility when backend auth is readable",
|
|
3154
3181
|
],
|
|
3155
3182
|
"minimal_example": {
|
|
@@ -3164,6 +3191,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
|
|
|
3164
3191
|
"execution_notes": [
|
|
3165
3192
|
"returns compact current field configuration for one app",
|
|
3166
3193
|
"use this before app_schema_apply when you need exact field definitions",
|
|
3194
|
+
"subtable fields include nested subfields using the same compact field shape",
|
|
3167
3195
|
],
|
|
3168
3196
|
"minimal_example": {
|
|
3169
3197
|
"profile": "default",
|
|
@@ -3269,14 +3297,15 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
|
|
|
3269
3297
|
"chart.filter.operator": [member.value for member in ViewFilterOperator],
|
|
3270
3298
|
**deepcopy(_VISIBILITY_ALLOWED_VALUES),
|
|
3271
3299
|
},
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3300
|
+
"execution_notes": [
|
|
3301
|
+
"app_charts_apply is immediate-live and does not publish",
|
|
3302
|
+
"chart matching precedence is chart_id first, then exact unique chart name",
|
|
3303
|
+
"when chart names are not unique, supply chart_id instead of guessing by name",
|
|
3304
|
+
"successful create results must return a real backend chart_id",
|
|
3305
|
+
"upsert_charts[].visibility compiles to QingBI base visibleAuth only",
|
|
3306
|
+
"visibility-only updates keep the existing chart config and do not rewrite rawDataConfigDTO.authInfo",
|
|
3307
|
+
*_VISIBILITY_EXECUTION_NOTES,
|
|
3308
|
+
],
|
|
3280
3309
|
"minimal_example": {
|
|
3281
3310
|
"profile": "default",
|
|
3282
3311
|
"app_key": "APP_KEY",
|
|
@@ -3332,14 +3361,15 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
|
|
|
3332
3361
|
"dashStyleConfigBO": "dash_style_config",
|
|
3333
3362
|
},
|
|
3334
3363
|
"allowed_values": {"section.source_type": ["chart", "view", "grid", "filter", "text", "link"], **deepcopy(_VISIBILITY_ALLOWED_VALUES)},
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3364
|
+
"execution_notes": [
|
|
3365
|
+
"use exactly one resource mode",
|
|
3366
|
+
"update mode: dash_key",
|
|
3367
|
+
"create mode: package_id + dash_name",
|
|
3368
|
+
"portal_apply uses replace semantics for sections",
|
|
3369
|
+
"when editing an existing portal, sections may be omitted to update only base info such as visibility, icon, or package",
|
|
3370
|
+
"remove a section by omitting it from the new sections list",
|
|
3371
|
+
"package_id is required when creating a new portal",
|
|
3372
|
+
"publish=false only guarantees draft and base-info updates; it does not claim live has changed",
|
|
3343
3373
|
"chart_ref resolves by chart_id first, then exact unique chart_name",
|
|
3344
3374
|
"view_ref resolves by view_key first, then exact unique view_name",
|
|
3345
3375
|
"position.pc/mobile is the canonical portal layout shape",
|
|
@@ -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,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
from typing import Any
|
|
4
5
|
|
|
5
6
|
from mcp.server.fastmcp import FastMCP
|
|
@@ -9,6 +10,7 @@ from ..config import (
|
|
|
9
10
|
DEFAULT_PROFILE,
|
|
10
11
|
get_default_base_url,
|
|
11
12
|
get_default_qf_version,
|
|
13
|
+
get_mcporter_config_path,
|
|
12
14
|
normalize_base_url,
|
|
13
15
|
)
|
|
14
16
|
from ..errors import QingflowApiError, raise_tool_error
|
|
@@ -77,15 +79,24 @@ class AuthTools(ToolBase):
|
|
|
77
79
|
profile: str = DEFAULT_PROFILE,
|
|
78
80
|
base_url: str | None = None,
|
|
79
81
|
qf_version: str | None = None,
|
|
80
|
-
credential: str,
|
|
82
|
+
credential: str | None = None,
|
|
81
83
|
persist: bool = False,
|
|
82
84
|
) -> dict[str, Any]:
|
|
83
85
|
"""执行认证与会话相关逻辑。"""
|
|
84
|
-
|
|
86
|
+
resolved_base_url, resolved_credential = self._resolve_mcporter_auth_inputs(
|
|
87
|
+
base_url=base_url,
|
|
88
|
+
credential=credential,
|
|
89
|
+
)
|
|
90
|
+
normalized_base_url = self._normalize_base_url(resolved_base_url)
|
|
85
91
|
normalized_qf_version, qf_version_source = self._resolve_qf_version_input(qf_version)
|
|
86
|
-
normalized_credential = str(
|
|
92
|
+
normalized_credential = str(resolved_credential).strip()
|
|
87
93
|
if not normalized_credential:
|
|
88
|
-
raise_tool_error(
|
|
94
|
+
raise_tool_error(
|
|
95
|
+
QingflowApiError.config_error(
|
|
96
|
+
"credential is required or configure ~/.openclaw/workspace/config/mcporter.json "
|
|
97
|
+
"with mcpServers.qingflow.headers.x-qingflow-client-id"
|
|
98
|
+
)
|
|
99
|
+
)
|
|
89
100
|
|
|
90
101
|
context_payload, detected_qf_version = self._fetch_auth_context(
|
|
91
102
|
normalized_base_url,
|
|
@@ -153,6 +164,186 @@ class AuthTools(ToolBase):
|
|
|
153
164
|
),
|
|
154
165
|
}
|
|
155
166
|
|
|
167
|
+
def auth_use_token(
|
|
168
|
+
self,
|
|
169
|
+
*,
|
|
170
|
+
profile: str = DEFAULT_PROFILE,
|
|
171
|
+
base_url: str | None = None,
|
|
172
|
+
qf_version: str | None = None,
|
|
173
|
+
token: str | None = None,
|
|
174
|
+
login_token: str | None = None,
|
|
175
|
+
persist: bool = False,
|
|
176
|
+
user_info: dict[str, Any] | None = None,
|
|
177
|
+
) -> dict[str, Any]:
|
|
178
|
+
"""使用已获得的 Qingflow token 建立本地会话。"""
|
|
179
|
+
normalized_base_url = self._normalize_base_url(base_url)
|
|
180
|
+
normalized_qf_version, qf_version_source = self._resolve_qf_version_input(qf_version)
|
|
181
|
+
normalized_token = self._normalize_text(token)
|
|
182
|
+
normalized_login_token = self._normalize_text(login_token)
|
|
183
|
+
if not normalized_token:
|
|
184
|
+
raise_tool_error(QingflowApiError.config_error("token is required"))
|
|
185
|
+
|
|
186
|
+
resolved_user_info = user_info if isinstance(user_info, dict) else None
|
|
187
|
+
response_qf_version: str | None = None
|
|
188
|
+
if resolved_user_info is None:
|
|
189
|
+
resolved_user_info, response_qf_version = self._fetch_user_info(
|
|
190
|
+
normalized_base_url,
|
|
191
|
+
normalized_token,
|
|
192
|
+
None,
|
|
193
|
+
qf_version=normalized_qf_version,
|
|
194
|
+
qf_version_source=qf_version_source,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
last_workspace = resolved_user_info.get("lastWsInfo")
|
|
198
|
+
selected_ws_id = self._coerce_positive_int(
|
|
199
|
+
last_workspace.get("wsId") if isinstance(last_workspace, dict) else None
|
|
200
|
+
)
|
|
201
|
+
selected_ws_name = self._normalize_text(
|
|
202
|
+
(last_workspace.get("wsName") if isinstance(last_workspace, dict) else None)
|
|
203
|
+
or (last_workspace.get("workspaceName") if isinstance(last_workspace, dict) else None)
|
|
204
|
+
or (last_workspace.get("remark") if isinstance(last_workspace, dict) else None)
|
|
205
|
+
)
|
|
206
|
+
workspace_qf_version = (
|
|
207
|
+
self._workspace_system_version(last_workspace) if isinstance(last_workspace, dict) else None
|
|
208
|
+
)
|
|
209
|
+
if selected_ws_id is None:
|
|
210
|
+
fallback_workspace, fallback_qf_version = self._fetch_first_workspace(
|
|
211
|
+
normalized_base_url,
|
|
212
|
+
normalized_token,
|
|
213
|
+
qf_version=normalized_qf_version,
|
|
214
|
+
qf_version_source=qf_version_source,
|
|
215
|
+
)
|
|
216
|
+
if isinstance(fallback_workspace, dict):
|
|
217
|
+
selected_ws_id = self._coerce_positive_int(fallback_workspace.get("wsId"))
|
|
218
|
+
selected_ws_name = self._normalize_text(
|
|
219
|
+
fallback_workspace.get("workspaceName")
|
|
220
|
+
or fallback_workspace.get("wsName")
|
|
221
|
+
or fallback_workspace.get("remark")
|
|
222
|
+
) or selected_ws_name
|
|
223
|
+
workspace_qf_version = self._workspace_system_version(fallback_workspace) or fallback_qf_version
|
|
224
|
+
elif selected_ws_name is None or workspace_qf_version is None:
|
|
225
|
+
workspace = self._fetch_workspace_with_name_fallback(
|
|
226
|
+
normalized_base_url,
|
|
227
|
+
normalized_token,
|
|
228
|
+
selected_ws_id,
|
|
229
|
+
qf_version=normalized_qf_version,
|
|
230
|
+
qf_version_source=qf_version_source,
|
|
231
|
+
)
|
|
232
|
+
if isinstance(workspace, dict):
|
|
233
|
+
selected_ws_name = self._normalize_text(
|
|
234
|
+
workspace.get("workspaceName")
|
|
235
|
+
or workspace.get("wsName")
|
|
236
|
+
or workspace.get("remark")
|
|
237
|
+
) or selected_ws_name
|
|
238
|
+
workspace_qf_version = self._workspace_system_version(workspace) or workspace_qf_version
|
|
239
|
+
|
|
240
|
+
if workspace_qf_version is not None:
|
|
241
|
+
resolved_qf_version, resolved_qf_version_source = workspace_qf_version, "workspace_system_version"
|
|
242
|
+
else:
|
|
243
|
+
resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
|
|
244
|
+
response_qf_version,
|
|
245
|
+
fallback_qf_version=normalized_qf_version,
|
|
246
|
+
fallback_source=qf_version_source,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
uid = self._coerce_positive_int(resolved_user_info.get("uid"))
|
|
250
|
+
if uid is None:
|
|
251
|
+
raise_tool_error(QingflowApiError(category="auth", message="Token validation did not return valid user info"))
|
|
252
|
+
|
|
253
|
+
session_profile = self.sessions.save_session(
|
|
254
|
+
profile=profile,
|
|
255
|
+
base_url=normalized_base_url,
|
|
256
|
+
qf_version=resolved_qf_version,
|
|
257
|
+
qf_version_source=resolved_qf_version_source,
|
|
258
|
+
token=normalized_token,
|
|
259
|
+
login_token=normalized_login_token,
|
|
260
|
+
credential=None,
|
|
261
|
+
uid=uid,
|
|
262
|
+
email=self._normalize_text(resolved_user_info.get("email")),
|
|
263
|
+
nick_name=self._normalize_text(
|
|
264
|
+
resolved_user_info.get("nickName")
|
|
265
|
+
or resolved_user_info.get("displayName")
|
|
266
|
+
or resolved_user_info.get("name")
|
|
267
|
+
),
|
|
268
|
+
persist=persist,
|
|
269
|
+
)
|
|
270
|
+
if selected_ws_id is not None:
|
|
271
|
+
session_profile = self.sessions.select_workspace(profile, ws_id=selected_ws_id, ws_name=selected_ws_name)
|
|
272
|
+
backend_session = self.sessions.get_backend_session(profile)
|
|
273
|
+
permission_level = (
|
|
274
|
+
self._workspace_permission_level(
|
|
275
|
+
session_profile=session_profile,
|
|
276
|
+
backend_session=backend_session,
|
|
277
|
+
)
|
|
278
|
+
if backend_session is not None
|
|
279
|
+
else None
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
"profile": session_profile.profile,
|
|
284
|
+
"base_url": session_profile.base_url,
|
|
285
|
+
"qf_version": session_profile.qf_version,
|
|
286
|
+
"qf_version_source": session_profile.qf_version_source,
|
|
287
|
+
"uid": session_profile.uid,
|
|
288
|
+
"email": session_profile.email,
|
|
289
|
+
"nick_name": session_profile.nick_name,
|
|
290
|
+
"selected_ws_id": session_profile.selected_ws_id,
|
|
291
|
+
"selected_ws_name": session_profile.selected_ws_name,
|
|
292
|
+
"suggested_ws_id": session_profile.selected_ws_id,
|
|
293
|
+
"suggested_ws_name": session_profile.selected_ws_name,
|
|
294
|
+
"permission_level": permission_level,
|
|
295
|
+
"persisted": session_profile.persisted,
|
|
296
|
+
"request_route": self._request_route_payload(
|
|
297
|
+
BackendRequestContext(
|
|
298
|
+
base_url=session_profile.base_url,
|
|
299
|
+
token=normalized_token,
|
|
300
|
+
ws_id=session_profile.selected_ws_id,
|
|
301
|
+
qf_version=session_profile.qf_version,
|
|
302
|
+
qf_version_source=session_profile.qf_version_source,
|
|
303
|
+
)
|
|
304
|
+
),
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
def _resolve_mcporter_auth_inputs(self, *, base_url: str | None, credential: str | None) -> tuple[str | None, str]:
|
|
308
|
+
"""从参数或 mcporter 配置解析登录所需 base_url 与 credential。"""
|
|
309
|
+
normalized_base_url = self._normalize_text(base_url)
|
|
310
|
+
normalized_credential = self._normalize_text(credential)
|
|
311
|
+
if normalized_base_url and normalized_credential:
|
|
312
|
+
return normalized_base_url, normalized_credential
|
|
313
|
+
|
|
314
|
+
mcporter_context = self._read_mcporter_qingflow_context()
|
|
315
|
+
if not normalized_base_url:
|
|
316
|
+
normalized_base_url = self._normalize_text(mcporter_context.get("base_url"))
|
|
317
|
+
if not normalized_credential:
|
|
318
|
+
normalized_credential = self._normalize_text(mcporter_context.get("credential"))
|
|
319
|
+
return normalized_base_url, normalized_credential or ""
|
|
320
|
+
|
|
321
|
+
def _read_mcporter_qingflow_context(self) -> dict[str, str]:
|
|
322
|
+
"""读取 OpenClaw mcporter 中的 Qingflow MCP 上下文。"""
|
|
323
|
+
path = get_mcporter_config_path()
|
|
324
|
+
if not path.exists():
|
|
325
|
+
return {}
|
|
326
|
+
try:
|
|
327
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
328
|
+
payload = json.load(handle)
|
|
329
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
330
|
+
raise_tool_error(QingflowApiError.config_error(f"failed to read mcporter config '{path}': {exc}"))
|
|
331
|
+
|
|
332
|
+
if not isinstance(payload, dict):
|
|
333
|
+
raise_tool_error(QingflowApiError.config_error(f"mcporter config '{path}' must be a JSON object"))
|
|
334
|
+
mcp_servers = payload.get("mcpServers")
|
|
335
|
+
qingflow = mcp_servers.get("qingflow") if isinstance(mcp_servers, dict) else None
|
|
336
|
+
if not isinstance(qingflow, dict):
|
|
337
|
+
return {}
|
|
338
|
+
headers = qingflow.get("headers")
|
|
339
|
+
credential = None
|
|
340
|
+
if isinstance(headers, dict):
|
|
341
|
+
credential = headers.get("x-qingflow-client-id")
|
|
342
|
+
return {
|
|
343
|
+
"base_url": str(qingflow.get("url") or "").strip(),
|
|
344
|
+
"credential": str(credential or "").strip(),
|
|
345
|
+
}
|
|
346
|
+
|
|
156
347
|
@tool_cn_name("我的身份")
|
|
157
348
|
def auth_whoami(self, *, profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
|
|
158
349
|
"""执行认证与会话相关逻辑。"""
|
|
@@ -161,6 +352,36 @@ class AuthTools(ToolBase):
|
|
|
161
352
|
backend_session, # type: ignore[no-untyped-def]
|
|
162
353
|
context: BackendRequestContext,
|
|
163
354
|
) -> dict[str, Any]:
|
|
355
|
+
workspace, workspace_qf_version = self._selected_workspace_snapshot(
|
|
356
|
+
session_profile=session_profile,
|
|
357
|
+
backend_session=backend_session,
|
|
358
|
+
)
|
|
359
|
+
resolved_qf_version = workspace_qf_version or session_profile.qf_version
|
|
360
|
+
resolved_qf_version_source = (
|
|
361
|
+
"workspace_system_version"
|
|
362
|
+
if workspace_qf_version is not None
|
|
363
|
+
else session_profile.qf_version_source
|
|
364
|
+
)
|
|
365
|
+
if (
|
|
366
|
+
workspace_qf_version is not None
|
|
367
|
+
and (
|
|
368
|
+
workspace_qf_version != session_profile.qf_version
|
|
369
|
+
or session_profile.qf_version_source != "workspace_system_version"
|
|
370
|
+
)
|
|
371
|
+
):
|
|
372
|
+
session_profile = self.sessions.update_route(
|
|
373
|
+
profile,
|
|
374
|
+
qf_version=workspace_qf_version,
|
|
375
|
+
qf_version_source="workspace_system_version",
|
|
376
|
+
)
|
|
377
|
+
backend_session = self.sessions.get_backend_session(profile) or backend_session
|
|
378
|
+
context = BackendRequestContext(
|
|
379
|
+
base_url=backend_session.base_url,
|
|
380
|
+
token=backend_session.token,
|
|
381
|
+
ws_id=session_profile.selected_ws_id,
|
|
382
|
+
qf_version=backend_session.qf_version,
|
|
383
|
+
qf_version_source=backend_session.qf_version_source,
|
|
384
|
+
)
|
|
164
385
|
if self._should_refresh_identity_metadata(session_profile):
|
|
165
386
|
refreshed_profile = self._refresh_identity_metadata(
|
|
166
387
|
profile=profile,
|
|
@@ -173,8 +394,8 @@ class AuthTools(ToolBase):
|
|
|
173
394
|
response = {
|
|
174
395
|
"profile": session_profile.profile,
|
|
175
396
|
"base_url": session_profile.base_url,
|
|
176
|
-
"qf_version":
|
|
177
|
-
"qf_version_source":
|
|
397
|
+
"qf_version": resolved_qf_version,
|
|
398
|
+
"qf_version_source": resolved_qf_version_source,
|
|
178
399
|
"uid": session_profile.uid,
|
|
179
400
|
"email": session_profile.email,
|
|
180
401
|
"nick_name": session_profile.nick_name,
|
|
@@ -461,6 +682,24 @@ class AuthTools(ToolBase):
|
|
|
461
682
|
raise_tool_error(QingflowApiError(category="workspace", message=f"Workspace {ws_id} is not accessible"))
|
|
462
683
|
return workspace
|
|
463
684
|
|
|
685
|
+
def _selected_workspace_snapshot(
|
|
686
|
+
self,
|
|
687
|
+
*,
|
|
688
|
+
session_profile, # type: ignore[no-untyped-def]
|
|
689
|
+
backend_session, # type: ignore[no-untyped-def]
|
|
690
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
691
|
+
ws_id = session_profile.selected_ws_id
|
|
692
|
+
if ws_id is None:
|
|
693
|
+
return None, None
|
|
694
|
+
workspace = self._fetch_workspace_with_name_fallback(
|
|
695
|
+
session_profile.base_url,
|
|
696
|
+
backend_session.token,
|
|
697
|
+
ws_id,
|
|
698
|
+
qf_version=session_profile.qf_version,
|
|
699
|
+
qf_version_source=session_profile.qf_version_source,
|
|
700
|
+
)
|
|
701
|
+
return workspace, self._workspace_system_version(workspace)
|
|
702
|
+
|
|
464
703
|
def _request_route_payload(self, context: BackendRequestContext) -> dict[str, Any]:
|
|
465
704
|
"""执行内部辅助逻辑。"""
|
|
466
705
|
describe_route = getattr(self.backend, "describe_route", None)
|
|
@@ -552,6 +791,13 @@ class AuthTools(ToolBase):
|
|
|
552
791
|
if ws_id is None:
|
|
553
792
|
return default_payload, []
|
|
554
793
|
|
|
794
|
+
permission_level = self._workspace_permission_level(
|
|
795
|
+
session_profile=session_profile,
|
|
796
|
+
backend_session=backend_session,
|
|
797
|
+
)
|
|
798
|
+
payload = dict(default_payload)
|
|
799
|
+
payload["permission_level"] = permission_level
|
|
800
|
+
|
|
555
801
|
context = BackendRequestContext(
|
|
556
802
|
base_url=backend_session.base_url,
|
|
557
803
|
token=backend_session.token,
|
|
@@ -559,12 +805,6 @@ class AuthTools(ToolBase):
|
|
|
559
805
|
qf_version=backend_session.qf_version,
|
|
560
806
|
qf_version_source=backend_session.qf_version_source,
|
|
561
807
|
)
|
|
562
|
-
permission_level = self._resolve_permission_level(
|
|
563
|
-
self._workspace_auth(context, ws_id=ws_id)
|
|
564
|
-
)
|
|
565
|
-
payload = dict(default_payload)
|
|
566
|
-
payload["permission_level"] = permission_level
|
|
567
|
-
|
|
568
808
|
member = self._lookup_current_member(
|
|
569
809
|
context=context,
|
|
570
810
|
uid=session_profile.uid,
|
|
@@ -586,6 +826,25 @@ class AuthTools(ToolBase):
|
|
|
586
826
|
payload["roles"] = self._compact_roles(member)
|
|
587
827
|
return payload, []
|
|
588
828
|
|
|
829
|
+
def _workspace_permission_level(
|
|
830
|
+
self,
|
|
831
|
+
*,
|
|
832
|
+
session_profile, # type: ignore[no-untyped-def]
|
|
833
|
+
backend_session, # type: ignore[no-untyped-def]
|
|
834
|
+
) -> str | None:
|
|
835
|
+
"""Resolve the selected workspace permission label without requiring member lookup."""
|
|
836
|
+
ws_id = session_profile.selected_ws_id
|
|
837
|
+
if ws_id is None:
|
|
838
|
+
return None
|
|
839
|
+
context = BackendRequestContext(
|
|
840
|
+
base_url=backend_session.base_url,
|
|
841
|
+
token=backend_session.token,
|
|
842
|
+
ws_id=ws_id,
|
|
843
|
+
qf_version=backend_session.qf_version,
|
|
844
|
+
qf_version_source=backend_session.qf_version_source,
|
|
845
|
+
)
|
|
846
|
+
return self._resolve_permission_level(self._workspace_auth(context, ws_id=ws_id))
|
|
847
|
+
|
|
589
848
|
def _workspace_auth(self, context: BackendRequestContext, *, ws_id: int) -> int | None:
|
|
590
849
|
"""执行内部辅助逻辑。"""
|
|
591
850
|
workspace = self._fetch_workspace_auth_from_detail(context, ws_id=ws_id)
|
|
@@ -47,7 +47,11 @@ class ToolBase:
|
|
|
47
47
|
context = BackendRequestContext(
|
|
48
48
|
base_url=backend_session.base_url,
|
|
49
49
|
token=backend_session.token,
|
|
50
|
-
|
|
50
|
+
# Keep the selected workspace in context whenever we know it.
|
|
51
|
+
# Some top-level tools (for example workspace browsing) do not
|
|
52
|
+
# require a workspace to execute, but credit metering still needs
|
|
53
|
+
# a stable wsId when the profile already has one selected.
|
|
54
|
+
ws_id=session_profile.selected_ws_id,
|
|
51
55
|
qf_version=backend_session.qf_version,
|
|
52
56
|
qf_version_source=backend_session.qf_version_source,
|
|
53
57
|
)
|
|
@@ -96,7 +100,7 @@ class ToolBase:
|
|
|
96
100
|
self.sessions.invalidate(profile)
|
|
97
101
|
error = QingflowApiError(
|
|
98
102
|
category="auth",
|
|
99
|
-
message=f"Qingflow session for profile '{profile}' has expired. Run auth_use_credential again.",
|
|
103
|
+
message=f"Qingflow session for profile '{profile}' has expired. Run auth login or auth_use_credential again.",
|
|
100
104
|
backend_code=error.backend_code,
|
|
101
105
|
request_id=error.request_id,
|
|
102
106
|
http_status=error.http_status,
|