@qingflow-tech/qingflow-cli 1.1.35 → 1.1.38

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
@@ -1,35 +1,63 @@
1
1
  # Qingflow CLI
2
2
 
3
- Install:
3
+ ## 安装
4
4
 
5
5
  ```bash
6
- npm install @qingflow-tech/qingflow-cli@1.1.35
6
+ npm install @qingflow-tech/qingflow-cli@1.1.38
7
7
  ```
8
8
 
9
- Run:
9
+ 推荐从官网复制这一条一键安装 Qingflow CLI:
10
10
 
11
11
  ```bash
12
- npx -y -p @qingflow-tech/qingflow-cli@1.1.35 qingflow
12
+ npx qingflow-cli@latest install
13
13
  ```
14
14
 
15
- Environment:
15
+ 如果你只想直接安装当前 npm 包,也可以使用上面的 `npm install` 命令。
16
+
17
+ ## 运行
18
+
19
+ ```bash
20
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.38 qingflow
21
+ ```
22
+
23
+ 全局安装后可以直接运行:
24
+
25
+ ```bash
26
+ qingflow
27
+ ```
28
+
29
+ ## 常用环境变量
16
30
 
17
31
  - `QINGFLOW_MCP_DEFAULT_BASE_URL`
18
32
  - `QINGFLOW_MCP_DEFAULT_QF_VERSION`
19
33
  - `QINGFLOW_MCP_HOME`
20
34
 
21
- This package bootstraps a local Python runtime on first install and then starts the `qingflow` command line interface.
35
+ 首次安装时会自动初始化本地 Python 运行环境,随后提供 `qingflow` 命令行入口。
22
36
 
23
- Bundled skills:
37
+ ## 内置 Skills
24
38
 
25
39
  - `skills/qingflow-cli`
26
40
  - `skills/qingflow-mcp-setup`
27
41
 
28
- Note:
42
+ ## Skills 安装
43
+
44
+ 包内已包含上面列出的 skill 文件,但安装 npm 包不会自动覆盖本机 agent skills。需要挂载到本地 agent 时,请显式执行:
45
+
46
+ ```bash
47
+ qingflow-skills list
48
+ qingflow-skills install --agent codex --scope user
49
+ ```
50
+
51
+ 如果你通过一次性 `npx -p` 执行 skills 安装,建议加 `--copy`,避免 symlink 指向 npm 临时执行缓存:
52
+
53
+ ```bash
54
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.38 qingflow-skills install --agent codex --scope user --copy
55
+ ```
56
+
57
+ `qingflow-skills install` 默认使用 symlink,且遇到同名 skill 不会覆盖;只有显式传入 `--force` 才会替换。
58
+
59
+ ## 排障
29
60
 
30
- - The skill files are included in the npm package.
31
- - Installing the npm package does not overwrite agent skills automatically.
32
- - To mount bundled skills from an installed package, run `qingflow-skills install --agent codex --scope user`.
33
- - For one-shot `npx -p` installs, prefer `--copy` because symlinks would point into the npm execution cache.
34
- - Use `qingflow-skills list` to inspect bundled skills. The installer defaults to symlinks for stable package installs and refuses to overwrite existing skills unless `--force` is provided.
35
- - If a stdio MCP client reports `Transport closed`, delete `.npm-python`, reinstall the package, and make sure CLI/user/builder packages are on the same version. The stdio entrypoints refuse runtime bootstrap so install logs never corrupt MCP stdout.
61
+ - 如果 npm 全局目录没有权限,推荐使用 `npx qingflow-cli@latest install`,安装器会自动 fallback 到用户目录。
62
+ - 如果本地 MCP 客户端报 `Transport closed`,优先删除安装目录里的 `.npm-python` 后重新安装,并确认 CLI/user/builder 包版本一致。
63
+ - stdio MCP 入口不会在启动瞬间重建 Python 运行时,避免安装日志写入 stdout 破坏 MCP 握手。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qingflow-tech/qingflow-cli",
3
- "version": "1.1.35",
3
+ "version": "1.1.38",
4
4
  "description": "Human-friendly Qingflow command line interface for auth, record operations, import, tasks, and stable builder flows.",
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 = "1.1.35"
7
+ version = "1.1.38"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -31,6 +31,8 @@ dependencies = [
31
31
  "pycryptodome>=3.20,<4.0",
32
32
  "pypdf>=5.0,<6.0",
33
33
  "python-socketio[client]>=5.11,<6.0",
34
+ "redis>=5.0,<6.0",
35
+ "uvicorn>=0.30,<1.0",
34
36
  ]
35
37
 
36
38
  [project.optional-dependencies]
@@ -10734,7 +10734,12 @@ class AiBuilderFacade:
10734
10734
  failed["suggested_next_call"] = suggested_next_call
10735
10735
  return finish(failed)
10736
10736
  flow_readback_started_at = time.perf_counter()
10737
- verified_nodes, verified_nodes_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
10737
+ verified_nodes, verified_nodes_unavailable = self._load_workflow_result(
10738
+ profile=profile,
10739
+ app_key=app_key,
10740
+ tolerate_404=True,
10741
+ legacy_fallback=True,
10742
+ )
10738
10743
  flow_readback_ms += _duration_ms(flow_readback_started_at)
10739
10744
  workflow_structure_verified = bool(verified_nodes) and _workflow_nodes_semantically_equal(
10740
10745
  current_workflow=verified_nodes,
@@ -14845,8 +14850,25 @@ class AiBuilderFacade:
14845
14850
  app_key: str,
14846
14851
  tolerate_404: bool,
14847
14852
  tolerate_permission_restricted: bool = False,
14853
+ legacy_fallback: bool = False,
14848
14854
  include_error: bool = False,
14849
14855
  ) -> tuple[Any, bool] | tuple[Any, bool, QingflowApiError | None]:
14856
+ def legacy_result() -> tuple[Any, bool, QingflowApiError | None]:
14857
+ try:
14858
+ legacy_workflow = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key)
14859
+ except (QingflowApiError, RuntimeError) as legacy_error:
14860
+ legacy_api_error = _coerce_api_error(legacy_error)
14861
+ if tolerate_404 and (
14862
+ legacy_api_error.http_status == 404
14863
+ or (tolerate_permission_restricted and _is_permission_restricted_api_error(legacy_api_error))
14864
+ ):
14865
+ return [], True, legacy_api_error
14866
+ raise
14867
+ legacy_payload = legacy_workflow.get("result") if isinstance(legacy_workflow, dict) and "result" in legacy_workflow else legacy_workflow
14868
+ if not legacy_fallback and not _summarize_workflow_nodes(legacy_payload):
14869
+ return [], True, None
14870
+ return legacy_payload, False, None
14871
+
14850
14872
  try:
14851
14873
  workflow = self._with_backend_context(
14852
14874
  profile,
@@ -14862,9 +14884,21 @@ class AiBuilderFacade:
14862
14884
  api_error.http_status == 404
14863
14885
  or (tolerate_permission_restricted and _is_permission_restricted_api_error(api_error))
14864
14886
  ):
14865
- return ([], True, api_error) if include_error else ([], True)
14887
+ if not legacy_fallback:
14888
+ return ([], True, api_error) if include_error else ([], True)
14889
+ legacy_workflow, legacy_unavailable, legacy_error = legacy_result()
14890
+ if not legacy_unavailable:
14891
+ return (legacy_workflow, False, None) if include_error else (legacy_workflow, False)
14892
+ return ([], True, legacy_error or api_error) if include_error else ([], True)
14866
14893
  raise
14867
14894
  result = workflow if _looks_like_workflow_spec_payload(workflow) else []
14895
+ if not result:
14896
+ if not legacy_fallback:
14897
+ return ([], True, None) if include_error else ([], True)
14898
+ legacy_workflow, legacy_unavailable, legacy_error = legacy_result()
14899
+ if not legacy_unavailable:
14900
+ return (legacy_workflow, False, None) if include_error else (legacy_workflow, False)
14901
+ return ([], True, legacy_error) if include_error else ([], True)
14868
14902
  return (result, False, None) if include_error else (result, False)
14869
14903
 
14870
14904
  def _load_app_state(self, *, profile: str, app_key: str) -> dict[str, Any]:
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from hashlib import sha256
5
+ from time import sleep, time
6
+ from typing import Any
7
+
8
+ from .backend_client import BackendClient, BackendResponse
9
+ from .cloud_context import CloudMcpContext
10
+ from .cloud_redis import CloudAuthCache, RedisCloudAuthCache
11
+ from .config import (
12
+ get_cloud_auth_context_lock_seconds,
13
+ get_cloud_auth_context_lock_wait_seconds,
14
+ get_cloud_auth_context_negative_ttl_seconds,
15
+ get_cloud_auth_context_ttl_seconds,
16
+ get_cloud_redis_url,
17
+ get_default_base_url,
18
+ get_default_qf_version,
19
+ normalize_base_url,
20
+ )
21
+ from .errors import QingflowApiError, is_auth_like_error
22
+ from .json_types import JSONObject
23
+
24
+
25
+ AUTH_CONTEXT_KEY_PREFIX = "mcp:auth:context"
26
+ AUTH_CONTEXT_LOCK_KEY_PREFIX = "mcp:auth:context:lock"
27
+ AUTH_CONTEXT_NEGATIVE_KEY_PREFIX = "mcp:auth:context:negative"
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class CloudAuthBroker:
32
+ cache: CloudAuthCache
33
+ backend: BackendClient
34
+ base_url: str
35
+ qf_version: str | None = None
36
+ ttl_seconds: int = 604800
37
+ lock_seconds: int = 10
38
+ negative_ttl_seconds: int = 30
39
+ lock_wait_seconds: float = 5.0
40
+
41
+ @classmethod
42
+ def from_config(cls, backend: BackendClient | None = None) -> "CloudAuthBroker":
43
+ redis_url = get_cloud_redis_url()
44
+ if not redis_url:
45
+ raise QingflowApiError.config_error("cloud.redis_url is required for Cloud MCP mode")
46
+ base_url = get_default_base_url()
47
+ if not base_url:
48
+ raise QingflowApiError.config_error(
49
+ "default_base_url is required for Cloud MCP mode"
50
+ )
51
+ return cls(
52
+ cache=RedisCloudAuthCache.from_url(redis_url),
53
+ backend=backend or BackendClient(),
54
+ base_url=base_url,
55
+ qf_version=get_default_qf_version(),
56
+ ttl_seconds=get_cloud_auth_context_ttl_seconds(),
57
+ lock_seconds=get_cloud_auth_context_lock_seconds(),
58
+ negative_ttl_seconds=get_cloud_auth_context_negative_ttl_seconds(),
59
+ lock_wait_seconds=get_cloud_auth_context_lock_wait_seconds(),
60
+ )
61
+
62
+ @classmethod
63
+ def from_env(cls, backend: BackendClient | None = None) -> "CloudAuthBroker":
64
+ return cls.from_config(backend=backend)
65
+
66
+ def resolve(self, credential: str, *, force_refresh: bool = False) -> CloudMcpContext:
67
+ normalized_credential = str(credential or "").strip()
68
+ if not normalized_credential:
69
+ raise QingflowApiError(category="auth", message="MCP credential is required")
70
+
71
+ credential_hash = self.credential_hash(normalized_credential)
72
+ auth_key = self._auth_key(credential_hash)
73
+ negative_key = self._negative_key(credential_hash)
74
+ if force_refresh:
75
+ self.cache.delete(auth_key)
76
+
77
+ cached = None if force_refresh else self.cache.get_json(auth_key)
78
+ if cached is not None:
79
+ return self._context_from_cache(credential_hash, normalized_credential, cached)
80
+ if self.cache.exists(negative_key):
81
+ raise QingflowApiError(category="auth", message="MCP credential is invalid or expired")
82
+
83
+ lock = self.cache.acquire_lock(self._lock_key(credential_hash), self.lock_seconds)
84
+ if not lock.acquired:
85
+ return self._wait_for_peer_context(credential_hash, normalized_credential)
86
+
87
+ try:
88
+ cached = None if force_refresh else self.cache.get_json(auth_key)
89
+ if cached is not None:
90
+ return self._context_from_cache(credential_hash, normalized_credential, cached)
91
+ if self.cache.exists(negative_key):
92
+ raise QingflowApiError(category="auth", message="MCP credential is invalid or expired")
93
+ payload = self._fetch_auth_context(normalized_credential, credential_hash)
94
+ self.cache.set_json(auth_key, payload, self.ttl_seconds)
95
+ return self._context_from_cache(credential_hash, normalized_credential, payload)
96
+ finally:
97
+ lock.release()
98
+
99
+ def refresh(self, context: CloudMcpContext) -> CloudMcpContext:
100
+ self.invalidate(context.credential_hash)
101
+ return self.resolve(context.credential, force_refresh=True)
102
+
103
+ def invalidate(self, credential_hash: str) -> None:
104
+ self.cache.delete(self._auth_key(credential_hash))
105
+
106
+ @staticmethod
107
+ def credential_hash(credential: str) -> str:
108
+ return sha256(credential.encode("utf-8")).hexdigest()
109
+
110
+ def _wait_for_peer_context(self, credential_hash: str, credential: str) -> CloudMcpContext:
111
+ auth_key = self._auth_key(credential_hash)
112
+ negative_key = self._negative_key(credential_hash)
113
+ deadline = time() + self.lock_wait_seconds
114
+ while time() < deadline:
115
+ cached = self.cache.get_json(auth_key)
116
+ if cached is not None:
117
+ return self._context_from_cache(credential_hash, credential, cached)
118
+ if self.cache.exists(negative_key):
119
+ raise QingflowApiError(category="auth", message="MCP credential is invalid or expired")
120
+ sleep(0.05)
121
+
122
+ lock = self.cache.acquire_lock(self._lock_key(credential_hash), self.lock_seconds)
123
+ if lock.acquired:
124
+ try:
125
+ cached = self.cache.get_json(auth_key)
126
+ if cached is not None:
127
+ return self._context_from_cache(credential_hash, credential, cached)
128
+ payload = self._fetch_auth_context(credential, credential_hash)
129
+ self.cache.set_json(auth_key, payload, self.ttl_seconds)
130
+ return self._context_from_cache(credential_hash, credential, payload)
131
+ finally:
132
+ lock.release()
133
+ raise QingflowApiError(category="cache", message="Timed out waiting for MCP auth context cache")
134
+
135
+ def _fetch_auth_context(self, credential: str, credential_hash: str) -> JSONObject:
136
+ try:
137
+ response = self.backend.public_request_with_meta(
138
+ "POST",
139
+ self.base_url,
140
+ "/mcp/auth/context",
141
+ json_body={"credential": credential},
142
+ qf_version=self.qf_version,
143
+ )
144
+ except QingflowApiError as error:
145
+ if is_auth_like_error(error):
146
+ self.cache.set_json(
147
+ self._negative_key(credential_hash),
148
+ {"message": error.message, "createdAt": int(time())},
149
+ self.negative_ttl_seconds,
150
+ )
151
+ raise
152
+
153
+ payload = self._unwrap_payload(response)
154
+ token = self._normalize_text(payload.get("token"))
155
+ uid = self._coerce_positive_int(payload.get("uid"))
156
+ ws_id = self._coerce_positive_int(payload.get("wsId") or payload.get("ws_id"))
157
+ if not token or uid is None or ws_id is None:
158
+ raise QingflowApiError(category="auth", message="Credential context did not return token, uid, and wsId")
159
+
160
+ base_url = normalize_base_url(self._normalize_text(payload.get("baseUrl"))) or self.base_url
161
+ qf_version = self._normalize_text(payload.get("qfVersion")) or response.qf_response_version or self.qf_version
162
+ qf_version_source = "backend_response" if qf_version else None
163
+ return {
164
+ "credentialId": self._coerce_positive_int(payload.get("credentialId") or payload.get("credential_id") or payload.get("id")),
165
+ "token": token,
166
+ "uid": uid,
167
+ "wsId": ws_id,
168
+ "baseUrl": base_url,
169
+ "qfVersion": qf_version,
170
+ "qfVersionSource": qf_version_source,
171
+ "email": self._normalize_text(payload.get("email")),
172
+ "nickName": self._normalize_text(
173
+ payload.get("nickName") or payload.get("displayName") or payload.get("name")
174
+ ),
175
+ "wsName": self._normalize_text(payload.get("wsName") or payload.get("workspaceName")),
176
+ "createdAt": int(time()),
177
+ }
178
+
179
+ def _context_from_cache(self, credential_hash: str, credential: str, payload: JSONObject) -> CloudMcpContext:
180
+ token = self._normalize_text(payload.get("token"))
181
+ uid = self._coerce_positive_int(payload.get("uid"))
182
+ ws_id = self._coerce_positive_int(payload.get("wsId") or payload.get("ws_id"))
183
+ base_url = normalize_base_url(self._normalize_text(payload.get("baseUrl"))) or self.base_url
184
+ if not token or uid is None or ws_id is None:
185
+ self.cache.delete(self._auth_key(credential_hash))
186
+ raise QingflowApiError(category="auth", message="Cached MCP auth context is incomplete")
187
+ context = CloudMcpContext(
188
+ credential_hash=credential_hash,
189
+ credential=credential,
190
+ credential_id=self._coerce_positive_int(
191
+ payload.get("credentialId") or payload.get("credential_id") or payload.get("id")
192
+ ),
193
+ token=token,
194
+ uid=uid,
195
+ ws_id=ws_id,
196
+ base_url=base_url,
197
+ qf_version=self._normalize_text(payload.get("qfVersion") or payload.get("qf_version")),
198
+ qf_version_source=self._normalize_text(payload.get("qfVersionSource") or payload.get("qf_version_source")),
199
+ email=self._normalize_text(payload.get("email")),
200
+ nick_name=self._normalize_text(payload.get("nickName") or payload.get("nick_name")),
201
+ ws_name=self._normalize_text(payload.get("wsName") or payload.get("ws_name")),
202
+ )
203
+ context.refresh_context = lambda: self.refresh(context)
204
+ return context
205
+
206
+ def _unwrap_payload(self, response: BackendResponse) -> JSONObject:
207
+ data = response.data
208
+ if not isinstance(data, dict):
209
+ raise QingflowApiError(category="auth", message="Credential context did not return a valid result")
210
+ for key in ("data", "result"):
211
+ nested = data.get(key)
212
+ if isinstance(nested, dict):
213
+ return nested
214
+ return data
215
+
216
+ def _auth_key(self, credential_hash: str) -> str:
217
+ return f"{AUTH_CONTEXT_KEY_PREFIX}:{credential_hash}"
218
+
219
+ def _lock_key(self, credential_hash: str) -> str:
220
+ return f"{AUTH_CONTEXT_LOCK_KEY_PREFIX}:{credential_hash}"
221
+
222
+ def _negative_key(self, credential_hash: str) -> str:
223
+ return f"{AUTH_CONTEXT_NEGATIVE_KEY_PREFIX}:{credential_hash}"
224
+
225
+ def _normalize_text(self, value: Any) -> str | None:
226
+ if value is None:
227
+ return None
228
+ text = str(value).strip()
229
+ return text or None
230
+
231
+ def _coerce_positive_int(self, value: Any) -> int | None:
232
+ if isinstance(value, bool) or value is None:
233
+ return None
234
+ try:
235
+ parsed = int(value)
236
+ except (TypeError, ValueError):
237
+ return None
238
+ return parsed if parsed > 0 else None
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from contextvars import ContextVar
4
+ from dataclasses import dataclass, field
5
+ from typing import Callable
6
+
7
+ from .backend_client import BackendRequestContext
8
+ from .session_store import BackendSession, SessionProfile
9
+
10
+
11
+ CLOUD_PROFILE_NAME = "__cloud_mcp__"
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class CloudMcpContext:
16
+ credential_hash: str
17
+ credential: str
18
+ token: str
19
+ uid: int
20
+ ws_id: int
21
+ base_url: str
22
+ qf_version: str | None = None
23
+ qf_version_source: str | None = None
24
+ credential_id: int | None = None
25
+ email: str | None = None
26
+ nick_name: str | None = None
27
+ ws_name: str | None = None
28
+ refresh_context: Callable[[], "CloudMcpContext"] | None = field(default=None, repr=False, compare=False)
29
+
30
+ def as_tool_context(self, profile: str) -> tuple[SessionProfile, BackendSession, BackendRequestContext]:
31
+ session_profile = SessionProfile(
32
+ profile=profile or CLOUD_PROFILE_NAME,
33
+ base_url=self.base_url,
34
+ qf_version=self.qf_version,
35
+ qf_version_source=self.qf_version_source,
36
+ token=self.token,
37
+ login_token=None,
38
+ credential=self.credential,
39
+ uid=self.uid,
40
+ email=self.email,
41
+ nick_name=self.nick_name,
42
+ selected_ws_id=self.ws_id,
43
+ selected_ws_name=self.ws_name,
44
+ persisted=False,
45
+ created_at="cloud-request",
46
+ updated_at="cloud-request",
47
+ )
48
+ backend_session = BackendSession(
49
+ token=self.token,
50
+ login_token=None,
51
+ credential=self.credential,
52
+ profile=session_profile.profile,
53
+ base_url=self.base_url,
54
+ qf_version=self.qf_version,
55
+ qf_version_source=self.qf_version_source,
56
+ )
57
+ request_context = BackendRequestContext(
58
+ base_url=self.base_url,
59
+ token=self.token,
60
+ ws_id=self.ws_id,
61
+ qf_version=self.qf_version,
62
+ qf_version_source=self.qf_version_source,
63
+ )
64
+ return session_profile, backend_session, request_context
65
+
66
+
67
+ current_cloud_context: ContextVar[CloudMcpContext | None] = ContextVar(
68
+ "qingflow_mcp_current_cloud_context",
69
+ default=None,
70
+ )
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any, Protocol
6
+ from uuid import uuid4
7
+
8
+ from .errors import QingflowApiError
9
+ from .json_types import JSONObject
10
+
11
+
12
+ class CloudAuthLock(Protocol):
13
+ acquired: bool
14
+
15
+ def release(self) -> None:
16
+ ...
17
+
18
+
19
+ class CloudAuthCache(Protocol):
20
+ def get_json(self, key: str) -> JSONObject | None:
21
+ ...
22
+
23
+ def set_json(self, key: str, value: JSONObject, ttl_seconds: int) -> None:
24
+ ...
25
+
26
+ def exists(self, key: str) -> bool:
27
+ ...
28
+
29
+ def delete(self, key: str) -> None:
30
+ ...
31
+
32
+ def acquire_lock(self, key: str, ttl_seconds: int) -> CloudAuthLock:
33
+ ...
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class RedisCloudAuthLock:
38
+ cache: "RedisCloudAuthCache"
39
+ key: str
40
+ token: str
41
+ acquired: bool
42
+
43
+ def release(self) -> None:
44
+ if not self.acquired:
45
+ return
46
+ self.cache.release_lock(self.key, self.token)
47
+
48
+
49
+ class RedisCloudAuthCache:
50
+ def __init__(self, client: Any) -> None:
51
+ self._client = client
52
+
53
+ @classmethod
54
+ def from_url(cls, redis_url: str) -> "RedisCloudAuthCache":
55
+ try:
56
+ import redis
57
+ except ImportError as exc:
58
+ raise QingflowApiError.config_error(
59
+ "Cloud MCP mode requires the 'redis' Python package. Install qingflow-mcp with cloud dependencies."
60
+ ) from exc
61
+ return cls(redis.Redis.from_url(redis_url, decode_responses=False))
62
+
63
+ def get_json(self, key: str) -> JSONObject | None:
64
+ raw_value = self._client.get(key)
65
+ if raw_value is None:
66
+ return None
67
+ if isinstance(raw_value, bytes):
68
+ raw_value = raw_value.decode("utf-8")
69
+ try:
70
+ value = json.loads(str(raw_value))
71
+ except json.JSONDecodeError as exc:
72
+ raise QingflowApiError(category="cache", message=f"invalid Redis JSON for key '{key}': {exc}") from exc
73
+ return value if isinstance(value, dict) else None
74
+
75
+ def set_json(self, key: str, value: JSONObject, ttl_seconds: int) -> None:
76
+ self._client.set(key, json.dumps(value, ensure_ascii=False), ex=max(1, int(ttl_seconds)))
77
+
78
+ def exists(self, key: str) -> bool:
79
+ return bool(self._client.exists(key))
80
+
81
+ def delete(self, key: str) -> None:
82
+ self._client.delete(key)
83
+
84
+ def acquire_lock(self, key: str, ttl_seconds: int) -> RedisCloudAuthLock:
85
+ token = uuid4().hex
86
+ acquired = bool(self._client.set(key, token, nx=True, ex=max(1, int(ttl_seconds))))
87
+ return RedisCloudAuthLock(cache=self, key=key, token=token, acquired=acquired)
88
+
89
+ def release_lock(self, key: str, token: str) -> None:
90
+ script = (
91
+ "if redis.call('get', KEYS[1]) == ARGV[1] then "
92
+ "return redis.call('del', KEYS[1]) "
93
+ "else return 0 end"
94
+ )
95
+ self._client.eval(script, 1, key, token)
@@ -23,6 +23,13 @@ DEFAULT_REPOSITORY_AUTHOR_EMAIL = "qingflow-mcp@local.invalid"
23
23
  DEFAULT_REPOSITORY_INTERNAL_SHARE_TOKEN_KEY = "tokenKey"
24
24
  DEFAULT_CREDIT_USAGE_RECORD_PATH = "/user/credit/usage"
25
25
  DEFAULT_MCPORTER_CONFIG_PATH = "~/.openclaw/workspace/config/mcporter.json"
26
+ DEFAULT_CLOUD_AUTH_CONTEXT_TTL_SECONDS = 604800
27
+ DEFAULT_CLOUD_AUTH_CONTEXT_LOCK_SECONDS = 10
28
+ DEFAULT_CLOUD_AUTH_CONTEXT_NEGATIVE_TTL_SECONDS = 30
29
+ DEFAULT_CLOUD_AUTH_CONTEXT_LOCK_WAIT_SECONDS = 5.0
30
+ DEFAULT_CLOUD_HTTP_HOST = "0.0.0.0"
31
+ DEFAULT_CLOUD_HTTP_PORT = 8000
32
+ DEFAULT_CLOUD_HTTP_ALLOWED_HOSTS = ("127.0.0.1", "localhost")
26
33
 
27
34
 
28
35
  def get_mcp_home() -> Path:
@@ -234,7 +241,7 @@ def get_credit_usage_base_url() -> str | None:
234
241
  default=None,
235
242
  )
236
243
  normalized = normalize_base_url(value)
237
- return normalized or None
244
+ return normalized or get_default_base_url()
238
245
 
239
246
 
240
247
  def get_credit_usage_path() -> str:
@@ -247,6 +254,99 @@ def get_credit_usage_path() -> str:
247
254
  return normalized or DEFAULT_CREDIT_USAGE_RECORD_PATH
248
255
 
249
256
 
257
+ def get_cloud_redis_url() -> str | None:
258
+ value = get_config_value(
259
+ "cloud.redis_url",
260
+ env_var="QINGFLOW_MCP_REDIS_URL",
261
+ default=None,
262
+ )
263
+ normalized = str(value or "").strip()
264
+ return normalized or None
265
+
266
+
267
+ def get_cloud_auth_context_ttl_seconds() -> int:
268
+ return _get_positive_int_config(
269
+ "cloud.auth_context_ttl_seconds",
270
+ "QINGFLOW_MCP_AUTH_CONTEXT_TTL_SECONDS",
271
+ DEFAULT_CLOUD_AUTH_CONTEXT_TTL_SECONDS,
272
+ )
273
+
274
+
275
+ def get_cloud_auth_context_lock_seconds() -> int:
276
+ return _get_positive_int_config(
277
+ "cloud.auth_context_lock_seconds",
278
+ "QINGFLOW_MCP_AUTH_CONTEXT_LOCK_SECONDS",
279
+ DEFAULT_CLOUD_AUTH_CONTEXT_LOCK_SECONDS,
280
+ )
281
+
282
+
283
+ def get_cloud_auth_context_negative_ttl_seconds() -> int:
284
+ return _get_positive_int_config(
285
+ "cloud.auth_context_negative_ttl_seconds",
286
+ "QINGFLOW_MCP_AUTH_CONTEXT_NEGATIVE_TTL_SECONDS",
287
+ DEFAULT_CLOUD_AUTH_CONTEXT_NEGATIVE_TTL_SECONDS,
288
+ )
289
+
290
+
291
+ def get_cloud_auth_context_lock_wait_seconds() -> float:
292
+ value = get_config_value(
293
+ "cloud.auth_context_lock_wait_seconds",
294
+ env_var="QINGFLOW_MCP_AUTH_CONTEXT_LOCK_WAIT_SECONDS",
295
+ default=DEFAULT_CLOUD_AUTH_CONTEXT_LOCK_WAIT_SECONDS,
296
+ )
297
+ try:
298
+ parsed = float(value)
299
+ except (TypeError, ValueError):
300
+ return DEFAULT_CLOUD_AUTH_CONTEXT_LOCK_WAIT_SECONDS
301
+ return parsed if parsed > 0 else DEFAULT_CLOUD_AUTH_CONTEXT_LOCK_WAIT_SECONDS
302
+
303
+
304
+ def get_cloud_http_host() -> str:
305
+ value = get_config_value(
306
+ "cloud.http.host",
307
+ env_var="QINGFLOW_MCP_HTTP_HOST",
308
+ default=DEFAULT_CLOUD_HTTP_HOST,
309
+ )
310
+ normalized = str(value or "").strip()
311
+ return normalized or DEFAULT_CLOUD_HTTP_HOST
312
+
313
+
314
+ def get_cloud_http_port() -> int:
315
+ value = get_config_value(
316
+ "cloud.http.port",
317
+ env_var="QINGFLOW_MCP_HTTP_PORT",
318
+ default=DEFAULT_CLOUD_HTTP_PORT,
319
+ )
320
+ try:
321
+ parsed = int(value)
322
+ except (TypeError, ValueError):
323
+ return DEFAULT_CLOUD_HTTP_PORT
324
+ return parsed if 0 < parsed < 65536 else DEFAULT_CLOUD_HTTP_PORT
325
+
326
+
327
+ def get_cloud_http_allowed_hosts() -> list[str]:
328
+ value = get_config_value(
329
+ "cloud.http.allowed_hosts",
330
+ env_var="QINGFLOW_MCP_HTTP_ALLOWED_HOSTS",
331
+ default=",".join(DEFAULT_CLOUD_HTTP_ALLOWED_HOSTS),
332
+ )
333
+ if isinstance(value, (list, tuple, set)):
334
+ raw_hosts = value
335
+ else:
336
+ raw_hosts = str(value or "").split(",")
337
+ hosts = [str(host or "").strip().lower() for host in raw_hosts]
338
+ return [host for host in hosts if host] or list(DEFAULT_CLOUD_HTTP_ALLOWED_HOSTS)
339
+
340
+
341
+ def _get_positive_int_config(key: str, env_var: str, default: int) -> int:
342
+ value = get_config_value(key, env_var=env_var, default=default)
343
+ try:
344
+ parsed = int(value)
345
+ except (TypeError, ValueError):
346
+ return default
347
+ return parsed if parsed > 0 else default
348
+
349
+
250
350
  def get_repository_default_group() -> str | None:
251
351
  value = get_config_value(
252
352
  "repository.default_group",
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Any
6
+
7
+ import anyio
8
+ import uvicorn
9
+
10
+ from .backend_client import BackendClient
11
+ from .cloud_auth import CloudAuthBroker
12
+ from .cloud_context import current_cloud_context
13
+ from .config import get_cloud_http_allowed_hosts, get_cloud_http_host, get_cloud_http_port
14
+ from .errors import QingflowApiError, is_auth_like_error
15
+ from .server_app_user import build_user_server
16
+
17
+
18
+ ASGIApp = Callable[
19
+ [
20
+ dict[str, Any],
21
+ Callable[[], Awaitable[dict[str, Any]]],
22
+ Callable[[dict[str, Any]], Awaitable[None]],
23
+ ],
24
+ Awaitable[None],
25
+ ]
26
+
27
+
28
+ class CloudMcpAuthApp:
29
+ def __init__(
30
+ self,
31
+ app: ASGIApp,
32
+ broker: CloudAuthBroker,
33
+ *,
34
+ allowed_hosts: list[str] | None = None,
35
+ internal_host: str | None = None,
36
+ ) -> None:
37
+ self._app = app
38
+ self._broker = broker
39
+ self._allowed_hosts = [
40
+ host.strip().lower()
41
+ for host in (allowed_hosts if allowed_hosts is not None else get_cloud_http_allowed_hosts())
42
+ if host.strip()
43
+ ]
44
+ self._internal_host = internal_host or f"127.0.0.1:{get_cloud_http_port()}"
45
+
46
+ async def __call__(
47
+ self,
48
+ scope: dict[str, Any],
49
+ receive: Callable[[], Awaitable[dict[str, Any]]],
50
+ send: Callable[[dict[str, Any]], Awaitable[None]],
51
+ ) -> None:
52
+ if scope.get("type") != "http":
53
+ await self._app(scope, receive, send)
54
+ return
55
+
56
+ host = _extract_host(scope)
57
+ if not _is_allowed_host(host, self._allowed_hosts):
58
+ await self._plain_response(send, 421, "Invalid Host header")
59
+ return
60
+ scope = _rewrite_host(scope, self._internal_host)
61
+
62
+ path = str(scope.get("path") or "")
63
+ if path in {"/health", "/healthz", "/readyz"}:
64
+ await self._json_response(send, 200, {"status": "ok"})
65
+ return
66
+
67
+ credential = self._extract_credential(scope)
68
+ if credential is None:
69
+ await self._json_response(
70
+ send,
71
+ 401,
72
+ {
73
+ "error": "missing_mcp_credential",
74
+ "message": "Authorization: Bearer <mcp_credential> is required",
75
+ },
76
+ )
77
+ return
78
+
79
+ try:
80
+ cloud_context = await anyio.to_thread.run_sync(self._broker.resolve, credential)
81
+ except QingflowApiError as error:
82
+ status = 401 if is_auth_like_error(error) else 500
83
+ await self._json_response(
84
+ send,
85
+ status,
86
+ {
87
+ "error": error.category,
88
+ "message": error.message,
89
+ "request_id": error.request_id,
90
+ "backend_code": error.backend_code,
91
+ },
92
+ )
93
+ return
94
+
95
+ token = current_cloud_context.set(cloud_context)
96
+ try:
97
+ await self._app(scope, receive, send)
98
+ finally:
99
+ current_cloud_context.reset(token)
100
+
101
+ def _extract_credential(self, scope: dict[str, Any]) -> str | None:
102
+ headers = {
103
+ key.decode("latin1").lower(): value.decode("latin1")
104
+ for key, value in scope.get("headers", [])
105
+ }
106
+ authorization = headers.get("authorization", "")
107
+ if authorization.lower().startswith("bearer "):
108
+ credential = authorization[7:].strip()
109
+ return credential or None
110
+ credential = headers.get("x-qingflow-client-id", "").strip()
111
+ return credential or None
112
+
113
+ async def _json_response(
114
+ self,
115
+ send: Callable[[dict[str, Any]], Awaitable[None]],
116
+ status: int,
117
+ payload: dict[str, Any],
118
+ ) -> None:
119
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
120
+ await send(
121
+ {
122
+ "type": "http.response.start",
123
+ "status": status,
124
+ "headers": [
125
+ (b"content-type", b"application/json; charset=utf-8"),
126
+ (b"content-length", str(len(body)).encode("ascii")),
127
+ ],
128
+ }
129
+ )
130
+ await send({"type": "http.response.body", "body": body})
131
+
132
+ async def _plain_response(
133
+ self,
134
+ send: Callable[[dict[str, Any]], Awaitable[None]],
135
+ status: int,
136
+ text: str,
137
+ ) -> None:
138
+ body = text.encode("utf-8")
139
+ await send(
140
+ {
141
+ "type": "http.response.start",
142
+ "status": status,
143
+ "headers": [
144
+ (b"content-type", b"text/plain; charset=utf-8"),
145
+ (b"content-length", str(len(body)).encode("ascii")),
146
+ ],
147
+ }
148
+ )
149
+ await send({"type": "http.response.body", "body": body})
150
+
151
+
152
+ class LazyCloudApp:
153
+ def __init__(self) -> None:
154
+ self._app: CloudMcpAuthApp | None = None
155
+
156
+ async def __call__(
157
+ self,
158
+ scope: dict[str, Any],
159
+ receive: Callable[[], Awaitable[dict[str, Any]]],
160
+ send: Callable[[dict[str, Any]], Awaitable[None]],
161
+ ) -> None:
162
+ if self._app is None:
163
+ self._app = build_cloud_user_app()
164
+ await self._app(scope, receive, send)
165
+
166
+
167
+ def build_cloud_user_app() -> CloudMcpAuthApp:
168
+ server = build_user_server()
169
+ broker = CloudAuthBroker.from_config(backend=BackendClient())
170
+ return CloudMcpAuthApp(server.streamable_http_app(), broker)
171
+
172
+
173
+ def _extract_host(scope: dict[str, Any]) -> str:
174
+ for key, value in scope.get("headers", []):
175
+ if key.lower() == b"host":
176
+ return value.decode("latin1").strip().lower()
177
+ server = scope.get("server")
178
+ if isinstance(server, tuple) and len(server) >= 2:
179
+ return f"{server[0]}:{server[1]}".lower()
180
+ return ""
181
+
182
+
183
+ def _is_allowed_host(host: str, allowed_hosts: list[str]) -> bool:
184
+ normalized_host = host.strip().lower().rstrip(".")
185
+ host_without_port = _strip_port(normalized_host)
186
+ for allowed in allowed_hosts:
187
+ normalized_allowed = allowed.strip().lower().rstrip(".")
188
+ if normalized_allowed == "*":
189
+ return True
190
+ if normalized_allowed.startswith("*."):
191
+ suffix = normalized_allowed[1:]
192
+ if host_without_port.endswith(suffix) and host_without_port != normalized_allowed[2:]:
193
+ return True
194
+ continue
195
+ if normalized_host == normalized_allowed or host_without_port == normalized_allowed:
196
+ return True
197
+ return False
198
+
199
+
200
+ def _strip_port(host: str) -> str:
201
+ if host.startswith("["):
202
+ end = host.find("]")
203
+ return host[1:end] if end > 0 else host
204
+ if host.count(":") == 1:
205
+ return host.split(":", 1)[0]
206
+ return host
207
+
208
+
209
+ def _rewrite_host(scope: dict[str, Any], internal_host: str) -> dict[str, Any]:
210
+ rewritten = dict(scope)
211
+ headers = []
212
+ host_seen = False
213
+ for key, value in scope.get("headers", []):
214
+ if key.lower() == b"host":
215
+ headers.append((key, internal_host.encode("latin1")))
216
+ host_seen = True
217
+ else:
218
+ headers.append((key, value))
219
+ if not host_seen:
220
+ headers.append((b"host", internal_host.encode("latin1")))
221
+ rewritten["headers"] = headers
222
+ return rewritten
223
+
224
+
225
+ app = LazyCloudApp()
226
+
227
+
228
+ def main() -> None:
229
+ uvicorn.run(build_cloud_user_app(), host=get_cloud_http_host(), port=get_cloud_http_port())
230
+
231
+
232
+ if __name__ == "__main__":
233
+ main()
@@ -3619,7 +3619,7 @@ class AiBuilderTools(ToolBase):
3619
3619
  normalized_args=normalized_args,
3620
3620
  suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
3621
3621
  ))
3622
- if nodes or transitions:
3622
+ if nodes is not None or transitions is not None:
3623
3623
  effective_nodes = nodes or []
3624
3624
  effective_transitions = transitions or []
3625
3625
  result = self._app_flow_apply_once(
@@ -3644,12 +3644,27 @@ class AiBuilderTools(ToolBase):
3644
3644
  )
3645
3645
  return _attach_builder_apply_envelope("app_flow_apply", result)
3646
3646
  normalized_args = {"app_key": app_key, "publish": publish}
3647
- return _attach_builder_apply_envelope("app_flow_apply", _failed(
3648
- "FLOW_SPEC_OR_PATCH_REQUIRED",
3649
- "app_flow_apply requires spec for a complete WorkflowSpec write or patch_nodes for targeted edits.",
3650
- normalized_args=normalized_args,
3651
- suggested_next_call={"tool_name": "app_flow_get_schema", "arguments": {"profile": profile}},
3652
- ))
3647
+ return _attach_builder_apply_envelope(
3648
+ "app_flow_apply",
3649
+ {
3650
+ "status": "failed",
3651
+ "error_code": "FLOW_SPEC_OR_PATCH_REQUIRED",
3652
+ "recoverable": True,
3653
+ "message": "app_flow_apply requires spec for a complete WorkflowSpec write or patch_nodes for targeted edits.",
3654
+ "normalized_args": normalized_args,
3655
+ "missing_fields": ["spec"],
3656
+ "allowed_values": {},
3657
+ "details": {},
3658
+ "suggested_next_call": {"tool_name": "app_flow_get_schema", "arguments": {"profile": profile}},
3659
+ "request_id": None,
3660
+ "backend_code": None,
3661
+ "http_status": None,
3662
+ "noop": False,
3663
+ "warnings": [],
3664
+ "verification": {},
3665
+ "verified": False,
3666
+ },
3667
+ )
3653
3668
 
3654
3669
  def _app_flow_apply_once(
3655
3670
  self,
@@ -7,6 +7,7 @@ import os
7
7
  from mcp.server.fastmcp import FastMCP
8
8
 
9
9
  from ..backend_client import BackendRequestContext, BackendResponse
10
+ from ..cloud_context import current_cloud_context
10
11
  from ..config import (
11
12
  DEFAULT_PROFILE,
12
13
  get_default_base_url,
@@ -378,6 +379,7 @@ class AuthTools(ToolBase):
378
379
  backend_session, # type: ignore[no-untyped-def]
379
380
  context: BackendRequestContext,
380
381
  ) -> dict[str, Any]:
382
+ cloud_context = current_cloud_context.get()
381
383
  workspace, workspace_qf_version = self._selected_workspace_snapshot(
382
384
  session_profile=session_profile,
383
385
  backend_session=backend_session,
@@ -389,7 +391,8 @@ class AuthTools(ToolBase):
389
391
  else session_profile.qf_version_source
390
392
  )
391
393
  if (
392
- workspace_qf_version is not None
394
+ cloud_context is None
395
+ and workspace_qf_version is not None
393
396
  and (
394
397
  workspace_qf_version != session_profile.qf_version
395
398
  or session_profile.qf_version_source != "workspace_system_version"
@@ -408,7 +411,7 @@ class AuthTools(ToolBase):
408
411
  qf_version=backend_session.qf_version,
409
412
  qf_version_source=backend_session.qf_version_source,
410
413
  )
411
- if self._should_refresh_identity_metadata(session_profile):
414
+ if cloud_context is None and self._should_refresh_identity_metadata(session_profile):
412
415
  refreshed_profile = self._refresh_identity_metadata(
413
416
  profile=profile,
414
417
  session_profile=session_profile,
@@ -443,13 +446,22 @@ class AuthTools(ToolBase):
443
446
  backend_session = None
444
447
  try:
445
448
  session_profile, backend_session, context = self._require_context(profile, require_workspace=False)
446
- if backend_session.credential:
449
+ if backend_session.credential and current_cloud_context.get() is None:
447
450
  self._probe_token_validity(
448
451
  session_profile=session_profile,
449
452
  backend_session=backend_session,
450
453
  )
451
454
  return build_response(session_profile, backend_session, context)
452
455
  except QingflowApiError as error:
456
+ if error.looks_like_invalid_token() and self._refresh_current_cloud_context():
457
+ try:
458
+ refreshed_profile, refreshed_backend_session, refreshed_context = self._require_context(
459
+ profile,
460
+ require_workspace=False,
461
+ )
462
+ return build_response(refreshed_profile, refreshed_backend_session, refreshed_context)
463
+ except QingflowApiError as refreshed_error:
464
+ self._handle_error(profile, refreshed_error)
453
465
  if (
454
466
  error.looks_like_invalid_token()
455
467
  and session_profile is not None
@@ -5,6 +5,7 @@ from contextvars import ContextVar
5
5
  from typing import Any, Callable, TypeVar
6
6
 
7
7
  from ..backend_client import BackendRequestContext, BackendClient
8
+ from ..cloud_context import current_cloud_context
8
9
  from ..config import (
9
10
  get_credit_meter_enabled,
10
11
  get_credit_usage_base_url,
@@ -36,6 +37,10 @@ class ToolBase:
36
37
  self.backend = backend
37
38
 
38
39
  def _require_context(self, profile: str, *, require_workspace: bool) -> tuple[SessionProfile, BackendSession, BackendRequestContext]:
40
+ cloud_context = current_cloud_context.get()
41
+ if cloud_context is not None:
42
+ return cloud_context.as_tool_context(profile)
43
+
39
44
  session_profile = self.sessions.get_profile(profile)
40
45
  if session_profile is None:
41
46
  raise QingflowApiError.auth_required(profile)
@@ -75,6 +80,12 @@ class ToolBase:
75
80
  )
76
81
  return func(session_profile, context)
77
82
  except QingflowApiError as error:
83
+ if error.looks_like_invalid_token() and self._refresh_current_cloud_context():
84
+ try:
85
+ refreshed_profile, _, refreshed_context = self._require_context(profile, require_workspace=require_workspace)
86
+ return func(refreshed_profile, refreshed_context)
87
+ except QingflowApiError as refreshed_error:
88
+ self._handle_error(profile, refreshed_error)
78
89
  if (
79
90
  error.looks_like_invalid_token()
80
91
  and session_profile is not None
@@ -97,6 +108,16 @@ class ToolBase:
97
108
 
98
109
  def _handle_error(self, profile: str, error: QingflowApiError) -> None:
99
110
  if error.looks_like_invalid_token():
111
+ cloud_context = current_cloud_context.get()
112
+ if cloud_context is not None:
113
+ refreshed_error = QingflowApiError(
114
+ category="auth",
115
+ message="Cloud MCP auth context expired and refresh failed. Retry with a valid MCP credential.",
116
+ backend_code=error.backend_code,
117
+ request_id=error.request_id,
118
+ http_status=error.http_status,
119
+ )
120
+ raise_tool_error(refreshed_error)
100
121
  self.sessions.invalidate(profile)
101
122
  error = QingflowApiError(
102
123
  category="auth",
@@ -107,6 +128,17 @@ class ToolBase:
107
128
  )
108
129
  raise_tool_error(error)
109
130
 
131
+ def _refresh_current_cloud_context(self) -> bool:
132
+ cloud_context = current_cloud_context.get()
133
+ if cloud_context is None or cloud_context.refresh_context is None:
134
+ return False
135
+ try:
136
+ refreshed_context = cloud_context.refresh_context()
137
+ except QingflowApiError:
138
+ return False
139
+ current_cloud_context.set(refreshed_context)
140
+ return True
141
+
110
142
  def _refresh_session_from_credential(
111
143
  self,
112
144
  profile: str,
@@ -5,6 +5,7 @@ from typing import Any
5
5
  from mcp.server.fastmcp import FastMCP
6
6
 
7
7
  from ..backend_client import BackendRequestContext
8
+ from ..cloud_context import current_cloud_context
8
9
  from ..config import DEFAULT_PROFILE
9
10
  from ..errors import QingflowApiError, backend_code_int, is_auth_like_error, raise_tool_error
10
11
  from .base import ToolBase, tool_cn_name
@@ -171,9 +172,35 @@ class WorkspaceTools(ToolBase):
171
172
  raise_tool_error(QingflowApiError.config_error("ws_id must be positive"))
172
173
 
173
174
  def runner(_, context):
175
+ cloud_context = current_cloud_context.get()
176
+ if cloud_context is not None and ws_id != cloud_context.ws_id:
177
+ raise_tool_error(
178
+ QingflowApiError.config_error(
179
+ "Cloud MCP workspace is bound to the configured MCP credential. Use a credential for the target workspace."
180
+ )
181
+ )
174
182
  workspace = self._fetch_workspace_with_fallback(context, ws_id=ws_id)
175
183
  workspace, warnings = _strip_workspace_detail_warning(workspace)
176
184
  workspace_name = str(workspace.get("workspaceName") or workspace.get("wsName") or workspace.get("remark") or "").strip() or None
185
+ if cloud_context is not None:
186
+ system_version = self._workspace_system_version(workspace)
187
+ qf_version_source = "workspace_system_version" if system_version else "unverified"
188
+ return {
189
+ "profile": profile,
190
+ "ws_id": ws_id,
191
+ "qf_version": system_version or cloud_context.qf_version,
192
+ "qf_version_source": qf_version_source if system_version else cloud_context.qf_version_source,
193
+ "workspace": workspace,
194
+ "selected": {
195
+ "ws_id": cloud_context.ws_id,
196
+ "workspace_name": workspace_name or cloud_context.ws_name,
197
+ },
198
+ "warnings": warnings,
199
+ "verification": {
200
+ "workspace_detail_verified": not warnings,
201
+ "workspace_list_fallback_used": bool(warnings),
202
+ },
203
+ }
177
204
  selected = self.sessions.select_workspace(profile, ws_id=ws_id, ws_name=workspace_name)
178
205
  system_version = self._workspace_system_version(workspace)
179
206
  qf_version_source = "workspace_system_version" if system_version else "unverified"