@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 +42 -14
- package/package.json +1 -1
- package/pyproject.toml +3 -1
- package/src/qingflow_mcp/builder_facade/service.py +36 -2
- package/src/qingflow_mcp/cloud_auth.py +238 -0
- package/src/qingflow_mcp/cloud_context.py +70 -0
- package/src/qingflow_mcp/cloud_redis.py +95 -0
- package/src/qingflow_mcp/config.py +101 -1
- package/src/qingflow_mcp/server_cloud.py +233 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +22 -7
- package/src/qingflow_mcp/tools/auth_tools.py +15 -3
- package/src/qingflow_mcp/tools/base.py +32 -0
- package/src/qingflow_mcp/tools/workspace_tools.py +27 -0
package/README.md
CHANGED
|
@@ -1,35 +1,63 @@
|
|
|
1
1
|
# Qingflow CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## 安装
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install @qingflow-tech/qingflow-cli@1.1.
|
|
6
|
+
npm install @qingflow-tech/qingflow-cli@1.1.38
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
推荐从官网复制这一条一键安装 Qingflow CLI:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx
|
|
12
|
+
npx qingflow-cli@latest install
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
|
|
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
|
-
|
|
35
|
+
首次安装时会自动初始化本地 Python 运行环境,随后提供 `qingflow` 命令行入口。
|
|
22
36
|
|
|
23
|
-
|
|
37
|
+
## 内置 Skills
|
|
24
38
|
|
|
25
39
|
- `skills/qingflow-cli`
|
|
26
40
|
- `skills/qingflow-mcp-setup`
|
|
27
41
|
|
|
28
|
-
|
|
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
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
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
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.
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
3648
|
-
"
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
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
|
-
|
|
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"
|