@qingflow-tech/qingflow-cli 1.1.40 → 1.1.42
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 +3 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md +1 -1
- package/skills/qingflow-cli/reference/task/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md +1 -1
- package/src/qingflow_mcp/cloud_auth.py +11 -0
- package/src/qingflow_mcp/cloud_context.py +5 -0
- package/src/qingflow_mcp/cloud_oauth_proxy.py +360 -0
- package/src/qingflow_mcp/cloud_redis.py +25 -0
- package/src/qingflow_mcp/config.py +44 -0
- package/src/qingflow_mcp/server_app_user.py +2 -1
- package/src/qingflow_mcp/server_cloud.py +180 -22
- package/src/qingflow_mcp/session_store.py +7 -1
- package/src/qingflow_mcp/tools/auth_tools.py +17 -9
- package/src/qingflow_mcp/tools/base.py +100 -41
- package/src/qingflow_mcp/tools/task_context_tools.py +6 -0
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Qingflow CLI 是轻流面向开发者和 AI Agent 的命令行工具。它把轻
|
|
|
7
7
|
## 安装
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm install @qingflow-tech/qingflow-cli@1.1.
|
|
10
|
+
npm install @qingflow-tech/qingflow-cli@1.1.42
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
推荐从官网复制这一条一键安装 Qingflow CLI:
|
|
@@ -21,7 +21,7 @@ npx qingflow-cli@latest install
|
|
|
21
21
|
## 运行
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.
|
|
24
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.42 qingflow
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
全局安装后可以直接运行:
|
|
@@ -55,7 +55,7 @@ qingflow-skills install --agent codex --scope user
|
|
|
55
55
|
如果你通过一次性 `npx -p` 执行 skills 安装,建议加 `--copy`,避免 symlink 指向 npm 临时执行缓存:
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.
|
|
58
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.42 qingflow-skills install --agent codex --scope user --copy
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
`qingflow-skills install` 默认使用 symlink,且遇到同名 skill 不会覆盖;只有显式传入 `--force` 才会替换。
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -114,7 +114,7 @@ qingflow --json task action \
|
|
|
114
114
|
- **定位只认 `task_id`**:来自 `task list` 的 `data.items[].task_id`;`app_key`、`record_id`、`workflow_node_id` 不是动作执行入参。
|
|
115
115
|
- `**approve` / `reject`**:执行上下文以当前待办详情或用户/前端已给出的显式 `formId` 为准;只有缺少 `formId` 时才兜底读取应用 baseInfo。不要把应用 baseInfo 的 `40002` 当作待办动作最终无权结论,最终是否可执行以后端审批动作接口为准。
|
|
116
116
|
- `**save_only`**:必须 提供非空 `**--fields-file`**(或 payload 与 fields 不能同时塞 `answers` 与 fields,具体以报错为准)。以当前待办节点的 `data.editable_fields` / `editableQueIds` 为准;若 `editableQueIds` 被当前权限链路挡住,但任务详情里的 `queAuthSetting` 已暴露可编辑字段,CLI 会按任务详情继续允许 save-only,并由最终保存接口裁决。申请表单 schema 或辅助 `editableQueIds` 的 `40002` 不是待办字段保存的最终无权结论。
|
|
117
|
-
- `**transfer**`:`**payload` 内需要** `target_member_id`(或别名 `uid` / `targetMemberId
|
|
117
|
+
- `**transfer**`:`**payload` 内需要** `target_member_id`(或别名 `uid` / `targetMemberId`);不能转给当前登录人。未提供 `payload.answers` 且没有通过 `--fields-file` 修改字段时,CLI 会先读取当前待办详情,并将已有 `answers` 原样带入转交请求;显式 `payload.answers` 或 `--fields-file` 合并结果优先。
|
|
118
118
|
- **意见必填**:若详情里 `**action_constraints.feedback_required_for`** 包含当前动作,需在 `**payload.audit_feedback`**(实现从 payload 抽取审计意见,具体键名以 `--help` / 服务端为准)中提供,否则会 config 错误。
|
|
119
119
|
- `**urge`**:**不支持** 与 `fields` 同时提交。
|
|
120
120
|
- **禁止臆测动作**:只允许 `**data.available_actions`** 中出现的动作;`approve` 不在列表则不可硬放行。
|
|
@@ -116,7 +116,7 @@ qingflow --json task action \
|
|
|
116
116
|
- **定位只认 `task_id`**:来自 `task list` 的 `data.items[].task_id`;`app_key`、`record_id`、`workflow_node_id` 不是动作执行入参。
|
|
117
117
|
- `**approve` / `reject`**:执行上下文以当前待办详情或用户/前端已给出的显式 `formId` 为准;只有缺少 `formId` 时才兜底读取应用 baseInfo。不要把应用 baseInfo 的 `40002` 当作待办动作最终无权结论,最终是否可执行以后端审批动作接口为准。
|
|
118
118
|
- `**save_only`**:必须 提供非空 `**--fields-file`**(或 payload 与 fields 不能同时塞 `answers` 与 fields,具体以报错为准)。以当前待办节点的 `data.editable_fields` / `editableQueIds` 为准;若 `editableQueIds` 被当前权限链路挡住,但任务详情里的 `queAuthSetting` 已暴露可编辑字段,CLI 会按任务详情继续允许 save-only,并由最终保存接口裁决。申请表单 schema 或辅助 `editableQueIds` 的 `40002` 不是待办字段保存的最终无权结论。
|
|
119
|
-
- `**transfer**`:`**payload` 内需要** `target_member_id`(或别名 `uid` / `targetMemberId
|
|
119
|
+
- `**transfer**`:`**payload` 内需要** `target_member_id`(或别名 `uid` / `targetMemberId`);不能转给当前登录人。未提供 `payload.answers` 且没有通过 `--fields-file` 修改字段时,CLI 会先读取当前待办详情,并将已有 `answers` 原样带入转交请求;显式 `payload.answers` 或 `--fields-file` 合并结果优先。
|
|
120
120
|
- **意见必填**:若详情里 `**action_constraints.feedback_required_for`** 包含当前动作,需在 `**payload.audit_feedback`**(实现从 payload 抽取审计意见,具体键名以 `--help` / 服务端为准)中提供,否则会 config 错误。
|
|
121
121
|
- `**urge`**:**不支持** 与 `fields` 同时提交。
|
|
122
122
|
- **禁止臆测动作**:只允许 `**data.available_actions`** 中出现的动作;`approve` 不在列表则不可硬放行。
|
|
@@ -25,6 +25,7 @@ from .json_types import JSONObject
|
|
|
25
25
|
AUTH_CONTEXT_KEY_PREFIX = "mcp:auth:context"
|
|
26
26
|
AUTH_CONTEXT_LOCK_KEY_PREFIX = "mcp:auth:context:lock"
|
|
27
27
|
AUTH_CONTEXT_NEGATIVE_KEY_PREFIX = "mcp:auth:context:negative"
|
|
28
|
+
ACCESS_PLATFORM_KEY_PREFIX = "oauth2:access-platform:"
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
@dataclass(slots=True)
|
|
@@ -103,6 +104,16 @@ class CloudAuthBroker:
|
|
|
103
104
|
def invalidate(self, credential_hash: str) -> None:
|
|
104
105
|
self.cache.delete(self._auth_key(credential_hash))
|
|
105
106
|
|
|
107
|
+
def resolve_call_platform(self, access_token: str) -> str:
|
|
108
|
+
normalized_token = self._normalize_text(access_token)
|
|
109
|
+
if normalized_token is None:
|
|
110
|
+
return ""
|
|
111
|
+
try:
|
|
112
|
+
value = self.cache.get_text(f"{ACCESS_PLATFORM_KEY_PREFIX}{normalized_token}")
|
|
113
|
+
except QingflowApiError:
|
|
114
|
+
return ""
|
|
115
|
+
return self._normalize_text(value) or ""
|
|
116
|
+
|
|
106
117
|
@staticmethod
|
|
107
118
|
def credential_hash(credential: str) -> str:
|
|
108
119
|
return sha256(credential.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .backend_client import BackendClient, BackendRequestContext
|
|
10
|
+
from .cloud_context import CloudMcpContext
|
|
11
|
+
from .config import (
|
|
12
|
+
get_cloud_oauth_resource_url,
|
|
13
|
+
get_cloud_oauth_service_base_url,
|
|
14
|
+
get_default_base_url,
|
|
15
|
+
get_default_qf_version,
|
|
16
|
+
get_timeout_seconds,
|
|
17
|
+
normalize_base_url,
|
|
18
|
+
)
|
|
19
|
+
from .errors import QingflowApiError, is_auth_like_error
|
|
20
|
+
from .json_types import JSONObject
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"
|
|
24
|
+
AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"
|
|
25
|
+
HOP_BY_HOP_RESPONSE_HEADERS = {
|
|
26
|
+
"connection",
|
|
27
|
+
"keep-alive",
|
|
28
|
+
"proxy-authenticate",
|
|
29
|
+
"proxy-authorization",
|
|
30
|
+
"te",
|
|
31
|
+
"trailer",
|
|
32
|
+
"transfer-encoding",
|
|
33
|
+
"upgrade",
|
|
34
|
+
"date",
|
|
35
|
+
"server",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class CloudOAuthProxyConfig:
|
|
41
|
+
service_base_url: str
|
|
42
|
+
resource_url: str
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_config(cls) -> "CloudOAuthProxyConfig | None":
|
|
46
|
+
resource_url = get_cloud_oauth_resource_url()
|
|
47
|
+
if resource_url is None:
|
|
48
|
+
return None
|
|
49
|
+
service_base_url = get_cloud_oauth_service_base_url()
|
|
50
|
+
if service_base_url is None:
|
|
51
|
+
raise QingflowApiError.config_error(
|
|
52
|
+
"cloud.oauth.service_base_url or default_base_url is required when OAuth is enabled"
|
|
53
|
+
)
|
|
54
|
+
return cls(
|
|
55
|
+
service_base_url=service_base_url,
|
|
56
|
+
resource_url=_validate_resource_url(resource_url),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def resource_metadata_url(self) -> str:
|
|
61
|
+
parsed = urlsplit(self.resource_url)
|
|
62
|
+
return urlunsplit(
|
|
63
|
+
(parsed.scheme, parsed.netloc, PROTECTED_RESOURCE_METADATA_PATH, "", "")
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def backend_metadata_path(self, request_path: str) -> str | None:
|
|
67
|
+
if request_path == PROTECTED_RESOURCE_METADATA_PATH or request_path.startswith(
|
|
68
|
+
f"{PROTECTED_RESOURCE_METADATA_PATH}/"
|
|
69
|
+
):
|
|
70
|
+
return PROTECTED_RESOURCE_METADATA_PATH
|
|
71
|
+
if request_path == AUTHORIZATION_SERVER_METADATA_PATH or request_path.startswith(
|
|
72
|
+
f"{AUTHORIZATION_SERVER_METADATA_PATH}/"
|
|
73
|
+
):
|
|
74
|
+
return AUTHORIZATION_SERVER_METADATA_PATH
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class OAuthProxyResponse:
|
|
80
|
+
status_code: int
|
|
81
|
+
headers: tuple[tuple[bytes, bytes], ...]
|
|
82
|
+
body: bytes
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class OAuthServiceProxy:
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
config: CloudOAuthProxyConfig,
|
|
89
|
+
*,
|
|
90
|
+
client: httpx.Client | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
self.config = config
|
|
93
|
+
self._owns_client = client is None
|
|
94
|
+
self._client = client or httpx.Client(
|
|
95
|
+
timeout=get_timeout_seconds(),
|
|
96
|
+
follow_redirects=False,
|
|
97
|
+
trust_env=False,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def close(self) -> None:
|
|
101
|
+
if self._owns_client:
|
|
102
|
+
self._client.close()
|
|
103
|
+
|
|
104
|
+
def request_metadata(
|
|
105
|
+
self,
|
|
106
|
+
method: str,
|
|
107
|
+
backend_path: str,
|
|
108
|
+
query_string: bytes = b"",
|
|
109
|
+
) -> OAuthProxyResponse:
|
|
110
|
+
url = f"{self.config.service_base_url}/{backend_path.lstrip('/')}"
|
|
111
|
+
if query_string:
|
|
112
|
+
url = f"{url}?{query_string.decode('latin1')}"
|
|
113
|
+
try:
|
|
114
|
+
response = self._client.request(
|
|
115
|
+
method.upper(),
|
|
116
|
+
url,
|
|
117
|
+
headers={"Accept": "application/json"},
|
|
118
|
+
)
|
|
119
|
+
except httpx.RequestError as error:
|
|
120
|
+
raise QingflowApiError(
|
|
121
|
+
category="network",
|
|
122
|
+
message=f"OAuth service request failed: {error}",
|
|
123
|
+
) from error
|
|
124
|
+
|
|
125
|
+
headers = tuple(
|
|
126
|
+
(name.encode("latin1"), value.encode("latin1"))
|
|
127
|
+
for name, value in response.headers.multi_items()
|
|
128
|
+
if name.lower() not in HOP_BY_HOP_RESPONSE_HEADERS
|
|
129
|
+
and name.lower() != "content-length"
|
|
130
|
+
)
|
|
131
|
+
return OAuthProxyResponse(
|
|
132
|
+
status_code=response.status_code,
|
|
133
|
+
headers=headers,
|
|
134
|
+
body=response.content,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(slots=True)
|
|
139
|
+
class OAuthAccessTokenResolver:
|
|
140
|
+
backend: BackendClient
|
|
141
|
+
base_url: str
|
|
142
|
+
qf_version: str | None = None
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def from_config(cls, backend: BackendClient) -> "OAuthAccessTokenResolver":
|
|
146
|
+
base_url = get_default_base_url()
|
|
147
|
+
if base_url is None:
|
|
148
|
+
raise QingflowApiError.config_error(
|
|
149
|
+
"default_base_url is required for OAuth access token requests"
|
|
150
|
+
)
|
|
151
|
+
return cls(
|
|
152
|
+
backend=backend,
|
|
153
|
+
base_url=base_url,
|
|
154
|
+
qf_version=get_default_qf_version(),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def resolve(self, access_token: str) -> CloudMcpContext:
|
|
158
|
+
normalized_token = str(access_token or "").strip()
|
|
159
|
+
if not normalized_token:
|
|
160
|
+
raise QingflowApiError(category="auth", message="OAuth access token is required")
|
|
161
|
+
|
|
162
|
+
user_info, workspace, response_qf_version = self._load_backend_context(
|
|
163
|
+
normalized_token
|
|
164
|
+
)
|
|
165
|
+
uid = self._coerce_positive_int(user_info.get("uid"))
|
|
166
|
+
ws_id = self._coerce_positive_int(workspace.get("wsId"))
|
|
167
|
+
if uid is None or ws_id is None:
|
|
168
|
+
raise QingflowApiError(
|
|
169
|
+
category="auth",
|
|
170
|
+
message="OAuth access token did not resolve a valid user and workspace",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
workspace_qf_version, workspace_qf_version_source = (
|
|
174
|
+
self._resolve_workspace_qf_version(workspace)
|
|
175
|
+
)
|
|
176
|
+
qf_version = workspace_qf_version or response_qf_version or self.qf_version
|
|
177
|
+
qf_version_source = (
|
|
178
|
+
workspace_qf_version_source
|
|
179
|
+
if workspace_qf_version
|
|
180
|
+
else "backend_response"
|
|
181
|
+
if response_qf_version
|
|
182
|
+
else "default_config"
|
|
183
|
+
if self.qf_version
|
|
184
|
+
else None
|
|
185
|
+
)
|
|
186
|
+
return CloudMcpContext(
|
|
187
|
+
credential_hash="",
|
|
188
|
+
credential="",
|
|
189
|
+
token=normalized_token,
|
|
190
|
+
uid=uid,
|
|
191
|
+
ws_id=ws_id,
|
|
192
|
+
base_url=self.base_url,
|
|
193
|
+
qf_version=qf_version,
|
|
194
|
+
qf_version_source=qf_version_source,
|
|
195
|
+
email=self._normalize_text(user_info.get("email")),
|
|
196
|
+
nick_name=self._normalize_text(
|
|
197
|
+
user_info.get("nickName")
|
|
198
|
+
or user_info.get("displayName")
|
|
199
|
+
or user_info.get("name")
|
|
200
|
+
),
|
|
201
|
+
ws_name=self._normalize_text(
|
|
202
|
+
workspace.get("workspaceName")
|
|
203
|
+
or workspace.get("wsName")
|
|
204
|
+
or workspace.get("remark")
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _load_backend_context(
|
|
209
|
+
self, access_token: str
|
|
210
|
+
) -> tuple[JSONObject, JSONObject, str | None]:
|
|
211
|
+
first_workspace: JSONObject | None = None
|
|
212
|
+
response_qf_version: str | None = None
|
|
213
|
+
try:
|
|
214
|
+
user_response = self.backend.request_with_meta(
|
|
215
|
+
"GET",
|
|
216
|
+
self._request_context(access_token, None),
|
|
217
|
+
"/user",
|
|
218
|
+
)
|
|
219
|
+
except QingflowApiError as original_error:
|
|
220
|
+
first_workspace, response_qf_version = self._fetch_first_workspace(
|
|
221
|
+
access_token
|
|
222
|
+
)
|
|
223
|
+
if first_workspace is None:
|
|
224
|
+
raise original_error
|
|
225
|
+
first_ws_id = self._coerce_positive_int(first_workspace.get("wsId"))
|
|
226
|
+
if first_ws_id is None:
|
|
227
|
+
raise original_error
|
|
228
|
+
user_response = self.backend.request_with_meta(
|
|
229
|
+
"GET",
|
|
230
|
+
self._request_context(
|
|
231
|
+
access_token, first_ws_id, response_qf_version
|
|
232
|
+
),
|
|
233
|
+
"/user",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
user_info = self._as_object(user_response.data)
|
|
237
|
+
if user_info is None:
|
|
238
|
+
raise QingflowApiError(
|
|
239
|
+
category="auth", message="OAuth access token did not resolve user info"
|
|
240
|
+
)
|
|
241
|
+
response_qf_version = user_response.qf_response_version or response_qf_version
|
|
242
|
+
|
|
243
|
+
workspace = self._as_object(user_info.get("lastWsInfo")) or first_workspace
|
|
244
|
+
if workspace is None:
|
|
245
|
+
workspace, workspace_response_version = self._fetch_first_workspace(
|
|
246
|
+
access_token
|
|
247
|
+
)
|
|
248
|
+
response_qf_version = workspace_response_version or response_qf_version
|
|
249
|
+
if workspace is None:
|
|
250
|
+
raise QingflowApiError(
|
|
251
|
+
category="auth",
|
|
252
|
+
message="OAuth access token user has no accessible workspace",
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
ws_id = self._coerce_positive_int(workspace.get("wsId"))
|
|
256
|
+
if ws_id is None:
|
|
257
|
+
raise QingflowApiError(
|
|
258
|
+
category="auth",
|
|
259
|
+
message="OAuth access token user has no valid workspace",
|
|
260
|
+
)
|
|
261
|
+
if self._resolve_workspace_qf_version(workspace)[0] is None:
|
|
262
|
+
try:
|
|
263
|
+
workspace_response = self.backend.request_with_meta(
|
|
264
|
+
"GET",
|
|
265
|
+
self._request_context(access_token, ws_id, response_qf_version),
|
|
266
|
+
f"/user/workspace/{ws_id}",
|
|
267
|
+
)
|
|
268
|
+
workspace = self._as_object(workspace_response.data) or workspace
|
|
269
|
+
response_qf_version = (
|
|
270
|
+
workspace_response.qf_response_version or response_qf_version
|
|
271
|
+
)
|
|
272
|
+
except QingflowApiError as error:
|
|
273
|
+
if is_auth_like_error(error):
|
|
274
|
+
raise
|
|
275
|
+
return user_info, workspace, response_qf_version
|
|
276
|
+
|
|
277
|
+
def _fetch_first_workspace(
|
|
278
|
+
self, access_token: str
|
|
279
|
+
) -> tuple[JSONObject | None, str | None]:
|
|
280
|
+
response = self.backend.request_with_meta(
|
|
281
|
+
"POST",
|
|
282
|
+
self._request_context(access_token, None),
|
|
283
|
+
"/user/workspaceList/pageQuery",
|
|
284
|
+
json_body={"pageNum": 1, "pageSize": 1},
|
|
285
|
+
)
|
|
286
|
+
page = self._as_object(response.data)
|
|
287
|
+
workspaces = page.get("list") if page else None
|
|
288
|
+
first = workspaces[0] if isinstance(workspaces, list) and workspaces else None
|
|
289
|
+
return self._as_object(first), response.qf_response_version
|
|
290
|
+
|
|
291
|
+
def _request_context(
|
|
292
|
+
self,
|
|
293
|
+
access_token: str,
|
|
294
|
+
ws_id: int | None,
|
|
295
|
+
qf_version: str | None = None,
|
|
296
|
+
) -> BackendRequestContext:
|
|
297
|
+
return BackendRequestContext(
|
|
298
|
+
base_url=self.base_url,
|
|
299
|
+
token=access_token,
|
|
300
|
+
ws_id=ws_id,
|
|
301
|
+
qf_version=qf_version or self.qf_version,
|
|
302
|
+
qf_version_source=(
|
|
303
|
+
"backend_response"
|
|
304
|
+
if qf_version
|
|
305
|
+
else "default_config"
|
|
306
|
+
if self.qf_version
|
|
307
|
+
else None
|
|
308
|
+
),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
@staticmethod
|
|
312
|
+
def _as_object(value: Any) -> JSONObject | None:
|
|
313
|
+
return value if isinstance(value, dict) else None
|
|
314
|
+
|
|
315
|
+
@classmethod
|
|
316
|
+
def _resolve_workspace_qf_version(
|
|
317
|
+
cls, workspace: JSONObject
|
|
318
|
+
) -> tuple[str | None, str | None]:
|
|
319
|
+
qf_version = cls._normalize_text(workspace.get("qfVersion"))
|
|
320
|
+
if qf_version is not None:
|
|
321
|
+
return qf_version, "workspace_qf_version"
|
|
322
|
+
system_version = cls._normalize_text(workspace.get("systemVersion"))
|
|
323
|
+
if system_version is not None:
|
|
324
|
+
return system_version, "workspace_system_version"
|
|
325
|
+
return None, None
|
|
326
|
+
|
|
327
|
+
@staticmethod
|
|
328
|
+
def _normalize_text(value: Any) -> str | None:
|
|
329
|
+
if value is None:
|
|
330
|
+
return None
|
|
331
|
+
text = str(value).strip()
|
|
332
|
+
return text or None
|
|
333
|
+
|
|
334
|
+
@staticmethod
|
|
335
|
+
def _coerce_positive_int(value: Any) -> int | None:
|
|
336
|
+
if isinstance(value, bool) or value is None:
|
|
337
|
+
return None
|
|
338
|
+
try:
|
|
339
|
+
parsed = int(value)
|
|
340
|
+
except (TypeError, ValueError):
|
|
341
|
+
return None
|
|
342
|
+
return parsed if parsed > 0 else None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _validate_resource_url(value: str) -> str:
|
|
346
|
+
parsed = urlsplit(value)
|
|
347
|
+
is_loopback_http = parsed.scheme == "http" and parsed.hostname in {
|
|
348
|
+
"localhost",
|
|
349
|
+
"127.0.0.1",
|
|
350
|
+
"::1",
|
|
351
|
+
}
|
|
352
|
+
if not parsed.netloc or (parsed.scheme != "https" and not is_loopback_http):
|
|
353
|
+
raise QingflowApiError.config_error(
|
|
354
|
+
"cloud.oauth.resource_url must use HTTPS (HTTP is allowed for loopback)"
|
|
355
|
+
)
|
|
356
|
+
if parsed.query or parsed.fragment or parsed.username or parsed.password:
|
|
357
|
+
raise QingflowApiError.config_error(
|
|
358
|
+
"cloud.oauth.resource_url must not contain user info, query parameters, or a fragment"
|
|
359
|
+
)
|
|
360
|
+
return normalize_base_url(value) or value.rstrip("/")
|
|
@@ -20,6 +20,9 @@ class CloudAuthCache(Protocol):
|
|
|
20
20
|
def get_json(self, key: str) -> JSONObject | None:
|
|
21
21
|
...
|
|
22
22
|
|
|
23
|
+
def get_text(self, key: str) -> str | None:
|
|
24
|
+
...
|
|
25
|
+
|
|
23
26
|
def set_json(self, key: str, value: JSONObject, ttl_seconds: int) -> None:
|
|
24
27
|
...
|
|
25
28
|
|
|
@@ -72,6 +75,28 @@ class RedisCloudAuthCache:
|
|
|
72
75
|
raise QingflowApiError(category="cache", message=f"invalid Redis JSON for key '{key}': {exc}") from exc
|
|
73
76
|
return value if isinstance(value, dict) else None
|
|
74
77
|
|
|
78
|
+
def get_text(self, key: str) -> str | None:
|
|
79
|
+
try:
|
|
80
|
+
raw_value = self._client.get(key)
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
raise QingflowApiError(
|
|
83
|
+
category="cache",
|
|
84
|
+
message="failed to read text value from Redis",
|
|
85
|
+
) from exc
|
|
86
|
+
if raw_value is None:
|
|
87
|
+
return None
|
|
88
|
+
if isinstance(raw_value, bytes):
|
|
89
|
+
try:
|
|
90
|
+
raw_value = raw_value.decode("utf-8")
|
|
91
|
+
except UnicodeDecodeError:
|
|
92
|
+
return None
|
|
93
|
+
text = str(raw_value)
|
|
94
|
+
try:
|
|
95
|
+
value = json.loads(text)
|
|
96
|
+
except json.JSONDecodeError:
|
|
97
|
+
value = text
|
|
98
|
+
return value if isinstance(value, str) else None
|
|
99
|
+
|
|
75
100
|
def set_json(self, key: str, value: JSONObject, ttl_seconds: int) -> None:
|
|
76
101
|
self._client.set(key, json.dumps(value, ensure_ascii=False), ex=max(1, int(ttl_seconds)))
|
|
77
102
|
|
|
@@ -301,6 +301,50 @@ def get_cloud_auth_context_lock_wait_seconds() -> float:
|
|
|
301
301
|
return parsed if parsed > 0 else DEFAULT_CLOUD_AUTH_CONTEXT_LOCK_WAIT_SECONDS
|
|
302
302
|
|
|
303
303
|
|
|
304
|
+
def get_cloud_oauth_service_base_url() -> str | None:
|
|
305
|
+
value = get_config_value(
|
|
306
|
+
"cloud.oauth.service_base_url",
|
|
307
|
+
env_var="QINGFLOW_MCP_OAUTH_SERVICE_BASE_URL",
|
|
308
|
+
default=None,
|
|
309
|
+
)
|
|
310
|
+
normalized = normalize_base_url(str(value).strip()) if value is not None else None
|
|
311
|
+
if normalized:
|
|
312
|
+
return normalized
|
|
313
|
+
_, service_base_url = _derive_cloud_oauth_urls()
|
|
314
|
+
return service_base_url
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def get_cloud_oauth_resource_url() -> str | None:
|
|
318
|
+
value = get_config_value(
|
|
319
|
+
"cloud.oauth.resource_url",
|
|
320
|
+
env_var="QINGFLOW_MCP_OAUTH_RESOURCE_URL",
|
|
321
|
+
default=None,
|
|
322
|
+
)
|
|
323
|
+
normalized = str(value or "").strip().rstrip("/")
|
|
324
|
+
if normalized:
|
|
325
|
+
return normalized
|
|
326
|
+
resource_url, _ = _derive_cloud_oauth_urls()
|
|
327
|
+
return resource_url
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _derive_cloud_oauth_urls() -> tuple[str | None, str | None]:
|
|
331
|
+
default_base_url = get_default_base_url()
|
|
332
|
+
if default_base_url is None:
|
|
333
|
+
return None, None
|
|
334
|
+
|
|
335
|
+
parsed = urlsplit(default_base_url)
|
|
336
|
+
path = parsed.path.rstrip("/")
|
|
337
|
+
if path.endswith("/api"):
|
|
338
|
+
resource_path = path[:-4]
|
|
339
|
+
else:
|
|
340
|
+
resource_path = path
|
|
341
|
+
|
|
342
|
+
resource_url = urlunsplit(
|
|
343
|
+
(parsed.scheme, parsed.netloc, resource_path, parsed.query, parsed.fragment)
|
|
344
|
+
)
|
|
345
|
+
return resource_url or None, default_base_url
|
|
346
|
+
|
|
347
|
+
|
|
304
348
|
def get_cloud_http_host() -> str:
|
|
305
349
|
value = get_config_value(
|
|
306
350
|
"cloud.http.host",
|
|
@@ -22,11 +22,12 @@ from .tools.task_context_tools import TaskContextTools
|
|
|
22
22
|
from .tools.workspace_tools import WorkspaceTools
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def build_user_server() -> FastMCP:
|
|
25
|
+
def build_user_server(*, stateless_http: bool = False) -> FastMCP:
|
|
26
26
|
today = date.today()
|
|
27
27
|
current_year = today.year
|
|
28
28
|
server = FastMCP(
|
|
29
29
|
"Qingflow App User MCP",
|
|
30
|
+
stateless_http=stateless_http,
|
|
30
31
|
instructions=f"""Use this server for Qingflow operational workflows. Current date: `{today.isoformat()}`.
|
|
31
32
|
|
|
32
33
|
## App Discovery
|
|
@@ -10,7 +10,13 @@ from mcp.server.fastmcp import FastMCP
|
|
|
10
10
|
|
|
11
11
|
from .backend_client import BackendClient
|
|
12
12
|
from .cloud_auth import CloudAuthBroker
|
|
13
|
-
from .cloud_context import current_cloud_context
|
|
13
|
+
from .cloud_context import CloudMcpContext, current_call_platform, current_cloud_context
|
|
14
|
+
from .cloud_oauth_proxy import (
|
|
15
|
+
CloudOAuthProxyConfig,
|
|
16
|
+
OAuthAccessTokenResolver,
|
|
17
|
+
OAuthProxyResponse,
|
|
18
|
+
OAuthServiceProxy,
|
|
19
|
+
)
|
|
14
20
|
from .config import get_cloud_http_allowed_hosts, get_cloud_http_host, get_cloud_http_port
|
|
15
21
|
from .errors import QingflowApiError, is_auth_like_error
|
|
16
22
|
from .server_app_builder import build_builder_server
|
|
@@ -36,11 +42,15 @@ class CloudMcpAuthApp:
|
|
|
36
42
|
app: ASGIApp,
|
|
37
43
|
broker: CloudAuthBroker,
|
|
38
44
|
*,
|
|
45
|
+
oauth_proxy: OAuthServiceProxy | None = None,
|
|
46
|
+
oauth_token_resolver: OAuthAccessTokenResolver | None = None,
|
|
39
47
|
allowed_hosts: list[str] | None = None,
|
|
40
48
|
internal_host: str | None = None,
|
|
41
49
|
) -> None:
|
|
42
50
|
self._app = app
|
|
43
51
|
self._broker = broker
|
|
52
|
+
self._oauth_proxy = oauth_proxy
|
|
53
|
+
self._oauth_token_resolver = oauth_token_resolver
|
|
44
54
|
self._allowed_hosts = [
|
|
45
55
|
host.strip().lower()
|
|
46
56
|
for host in (allowed_hosts if allowed_hosts is not None else get_cloud_http_allowed_hosts())
|
|
@@ -69,22 +79,43 @@ class CloudMcpAuthApp:
|
|
|
69
79
|
await self._json_response(send, 200, {"status": "ok"})
|
|
70
80
|
return
|
|
71
81
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
if self._oauth_proxy is not None:
|
|
83
|
+
backend_metadata_path = self._oauth_proxy.config.backend_metadata_path(path)
|
|
84
|
+
if backend_metadata_path is not None:
|
|
85
|
+
await self._proxy_oauth_metadata(
|
|
86
|
+
scope,
|
|
87
|
+
send,
|
|
88
|
+
backend_metadata_path,
|
|
89
|
+
)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
auth_value, explicit_credential, call_platform = self._extract_auth_value(scope)
|
|
93
|
+
if auth_value is None:
|
|
94
|
+
await self._authentication_required(send)
|
|
82
95
|
return
|
|
83
96
|
|
|
97
|
+
use_oauth = (
|
|
98
|
+
not explicit_credential
|
|
99
|
+
and self._oauth_token_resolver is not None
|
|
100
|
+
and not auth_value.startswith("mcp_")
|
|
101
|
+
)
|
|
84
102
|
try:
|
|
85
|
-
|
|
103
|
+
if use_oauth:
|
|
104
|
+
assert self._oauth_token_resolver is not None
|
|
105
|
+
cloud_context = await anyio.to_thread.run_sync(
|
|
106
|
+
self._resolve_oauth_or_legacy_credential,
|
|
107
|
+
auth_value,
|
|
108
|
+
)
|
|
109
|
+
else:
|
|
110
|
+
cloud_context = await anyio.to_thread.run_sync(
|
|
111
|
+
self._broker.resolve,
|
|
112
|
+
auth_value,
|
|
113
|
+
)
|
|
86
114
|
except QingflowApiError as error:
|
|
87
115
|
status = 401 if is_auth_like_error(error) else 500
|
|
116
|
+
if status == 401 and use_oauth:
|
|
117
|
+
await self._oauth_authentication_required(send, "invalid_token", error.message)
|
|
118
|
+
return
|
|
88
119
|
await self._json_response(
|
|
89
120
|
send,
|
|
90
121
|
status,
|
|
@@ -97,30 +128,119 @@ class CloudMcpAuthApp:
|
|
|
97
128
|
)
|
|
98
129
|
return
|
|
99
130
|
|
|
100
|
-
|
|
131
|
+
if not call_platform:
|
|
132
|
+
call_platform = await anyio.to_thread.run_sync(
|
|
133
|
+
self._broker.resolve_call_platform,
|
|
134
|
+
cloud_context.token,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
context_token = current_cloud_context.set(cloud_context)
|
|
138
|
+
call_platform_token = current_call_platform.set(call_platform)
|
|
101
139
|
try:
|
|
102
140
|
scope = _ensure_streamable_http_accept(scope)
|
|
103
141
|
await self._app(scope, receive, send)
|
|
104
142
|
finally:
|
|
105
|
-
|
|
143
|
+
current_call_platform.reset(call_platform_token)
|
|
144
|
+
current_cloud_context.reset(context_token)
|
|
106
145
|
|
|
107
|
-
def
|
|
146
|
+
def _resolve_oauth_or_legacy_credential(self, auth_value: str) -> CloudMcpContext:
|
|
147
|
+
assert self._oauth_token_resolver is not None
|
|
148
|
+
try:
|
|
149
|
+
return self._oauth_token_resolver.resolve(auth_value)
|
|
150
|
+
except QingflowApiError as oauth_error:
|
|
151
|
+
if not is_auth_like_error(oauth_error):
|
|
152
|
+
raise
|
|
153
|
+
try:
|
|
154
|
+
return self._broker.resolve(auth_value)
|
|
155
|
+
except QingflowApiError:
|
|
156
|
+
raise oauth_error
|
|
157
|
+
|
|
158
|
+
def _extract_auth_value(self, scope: dict[str, Any]) -> tuple[str | None, bool, str]:
|
|
108
159
|
headers = {
|
|
109
160
|
key.decode("latin1").lower(): value.decode("latin1")
|
|
110
161
|
for key, value in scope.get("headers", [])
|
|
111
162
|
}
|
|
163
|
+
call_platform = headers.get("x-qf-callplatform", "").strip()
|
|
112
164
|
authorization = headers.get("authorization", "")
|
|
113
165
|
if authorization.lower().startswith("bearer "):
|
|
114
|
-
|
|
115
|
-
return
|
|
166
|
+
bearer_token = authorization[7:].strip()
|
|
167
|
+
return bearer_token or None, False, call_platform
|
|
116
168
|
credential = headers.get("x-qingflow-client-id", "").strip()
|
|
117
|
-
return credential or None
|
|
169
|
+
return credential or None, True, call_platform
|
|
170
|
+
|
|
171
|
+
async def _authentication_required(
|
|
172
|
+
self,
|
|
173
|
+
send: Callable[[dict[str, Any]], Awaitable[None]],
|
|
174
|
+
) -> None:
|
|
175
|
+
if self._oauth_proxy is not None:
|
|
176
|
+
await self._oauth_authentication_required(
|
|
177
|
+
send,
|
|
178
|
+
"invalid_token",
|
|
179
|
+
"Authentication required",
|
|
180
|
+
)
|
|
181
|
+
return
|
|
182
|
+
await self._json_response(
|
|
183
|
+
send,
|
|
184
|
+
401,
|
|
185
|
+
{
|
|
186
|
+
"error": "missing_mcp_credential",
|
|
187
|
+
"message": "Authorization: Bearer <mcp_credential> is required",
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
async def _oauth_authentication_required(
|
|
192
|
+
self,
|
|
193
|
+
send: Callable[[dict[str, Any]], Awaitable[None]],
|
|
194
|
+
error: str,
|
|
195
|
+
description: str,
|
|
196
|
+
) -> None:
|
|
197
|
+
assert self._oauth_proxy is not None
|
|
198
|
+
challenge = (
|
|
199
|
+
f'Bearer error="{_escape_auth_param(error)}", '
|
|
200
|
+
f'error_description="{_escape_auth_param(description)}", '
|
|
201
|
+
f'resource_metadata="{self._oauth_proxy.config.resource_metadata_url}"'
|
|
202
|
+
)
|
|
203
|
+
await self._json_response(
|
|
204
|
+
send,
|
|
205
|
+
401,
|
|
206
|
+
{"error": error, "error_description": description},
|
|
207
|
+
extra_headers=[
|
|
208
|
+
(b"www-authenticate", challenge.encode("latin1", errors="replace")),
|
|
209
|
+
(b"cache-control", b"no-store"),
|
|
210
|
+
(b"pragma", b"no-cache"),
|
|
211
|
+
],
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
async def _proxy_oauth_metadata(
|
|
215
|
+
self,
|
|
216
|
+
scope: dict[str, Any],
|
|
217
|
+
send: Callable[[dict[str, Any]], Awaitable[None]],
|
|
218
|
+
backend_path: str,
|
|
219
|
+
) -> None:
|
|
220
|
+
assert self._oauth_proxy is not None
|
|
221
|
+
try:
|
|
222
|
+
response = await anyio.to_thread.run_sync(
|
|
223
|
+
self._oauth_proxy.request_metadata,
|
|
224
|
+
str(scope.get("method") or "GET"),
|
|
225
|
+
backend_path,
|
|
226
|
+
bytes(scope.get("query_string") or b""),
|
|
227
|
+
)
|
|
228
|
+
except QingflowApiError as error:
|
|
229
|
+
await self._json_response(
|
|
230
|
+
send,
|
|
231
|
+
502,
|
|
232
|
+
{"error": "oauth_service_unavailable", "message": error.message},
|
|
233
|
+
)
|
|
234
|
+
return
|
|
235
|
+
await self._raw_response(send, response)
|
|
118
236
|
|
|
119
237
|
async def _json_response(
|
|
120
238
|
self,
|
|
121
239
|
send: Callable[[dict[str, Any]], Awaitable[None]],
|
|
122
240
|
status: int,
|
|
123
241
|
payload: dict[str, Any],
|
|
242
|
+
*,
|
|
243
|
+
extra_headers: list[tuple[bytes, bytes]] | None = None,
|
|
124
244
|
) -> None:
|
|
125
245
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
126
246
|
await send(
|
|
@@ -130,11 +250,26 @@ class CloudMcpAuthApp:
|
|
|
130
250
|
"headers": [
|
|
131
251
|
(b"content-type", b"application/json; charset=utf-8"),
|
|
132
252
|
(b"content-length", str(len(body)).encode("ascii")),
|
|
133
|
-
],
|
|
253
|
+
] + list(extra_headers or []),
|
|
134
254
|
}
|
|
135
255
|
)
|
|
136
256
|
await send({"type": "http.response.body", "body": body})
|
|
137
257
|
|
|
258
|
+
async def _raw_response(
|
|
259
|
+
self,
|
|
260
|
+
send: Callable[[dict[str, Any]], Awaitable[None]],
|
|
261
|
+
response: OAuthProxyResponse,
|
|
262
|
+
) -> None:
|
|
263
|
+
await send(
|
|
264
|
+
{
|
|
265
|
+
"type": "http.response.start",
|
|
266
|
+
"status": response.status_code,
|
|
267
|
+
"headers": list(response.headers)
|
|
268
|
+
+ [(b"content-length", str(len(response.body)).encode("ascii"))],
|
|
269
|
+
}
|
|
270
|
+
)
|
|
271
|
+
await send({"type": "http.response.body", "body": response.body})
|
|
272
|
+
|
|
138
273
|
async def _plain_response(
|
|
139
274
|
self,
|
|
140
275
|
send: Callable[[dict[str, Any]], Awaitable[None]],
|
|
@@ -176,12 +311,25 @@ def build_cloud_user_app() -> CloudMcpAuthApp:
|
|
|
176
311
|
|
|
177
312
|
def build_cloud_app() -> CloudMcpAuthApp:
|
|
178
313
|
server = build_combined_server()
|
|
179
|
-
|
|
180
|
-
|
|
314
|
+
backend = BackendClient()
|
|
315
|
+
broker = CloudAuthBroker.from_config(backend=backend)
|
|
316
|
+
oauth_config = CloudOAuthProxyConfig.from_config()
|
|
317
|
+
oauth_proxy = OAuthServiceProxy(oauth_config) if oauth_config is not None else None
|
|
318
|
+
oauth_token_resolver = (
|
|
319
|
+
OAuthAccessTokenResolver.from_config(backend)
|
|
320
|
+
if oauth_config is not None
|
|
321
|
+
else None
|
|
322
|
+
)
|
|
323
|
+
return CloudMcpAuthApp(
|
|
324
|
+
server.streamable_http_app(),
|
|
325
|
+
broker,
|
|
326
|
+
oauth_proxy=oauth_proxy,
|
|
327
|
+
oauth_token_resolver=oauth_token_resolver,
|
|
328
|
+
)
|
|
181
329
|
|
|
182
330
|
|
|
183
331
|
def build_combined_server() -> FastMCP:
|
|
184
|
-
server = build_user_server()
|
|
332
|
+
server = build_user_server(stateless_http=True)
|
|
185
333
|
builder_server = build_builder_server()
|
|
186
334
|
server._mcp_server.name = "Qingflow Combined MCP"
|
|
187
335
|
_register_prefixed_builder_tools(server, builder_server)
|
|
@@ -308,6 +456,16 @@ def _ensure_streamable_http_accept(scope: dict[str, Any]) -> dict[str, Any]:
|
|
|
308
456
|
return rewritten
|
|
309
457
|
|
|
310
458
|
|
|
459
|
+
def _escape_auth_param(value: str) -> str:
|
|
460
|
+
return (
|
|
461
|
+
str(value or "")
|
|
462
|
+
.replace("\\", "\\\\")
|
|
463
|
+
.replace('"', '\\"')
|
|
464
|
+
.replace("\r", " ")
|
|
465
|
+
.replace("\n", " ")
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
311
469
|
app = LazyCloudApp()
|
|
312
470
|
|
|
313
471
|
|
|
@@ -105,7 +105,7 @@ class SessionStore:
|
|
|
105
105
|
persist: bool,
|
|
106
106
|
) -> SessionProfile:
|
|
107
107
|
now = _utcnow()
|
|
108
|
-
previous = self.
|
|
108
|
+
previous = self.peek_profile(profile)
|
|
109
109
|
persisted = False
|
|
110
110
|
if persist:
|
|
111
111
|
persisted = self._set_secret(self._token_key(profile), token)
|
|
@@ -163,6 +163,12 @@ class SessionStore:
|
|
|
163
163
|
session_profile = SessionProfile.from_dict(raw_profile) if isinstance(raw_profile, dict) else None
|
|
164
164
|
return session_profile
|
|
165
165
|
|
|
166
|
+
def peek_profile(self, profile: str) -> SessionProfile | None:
|
|
167
|
+
"""读取 profile,但不触发缺失、过期会话的自动刷新。"""
|
|
168
|
+
payload = self._load_profiles()
|
|
169
|
+
raw_profile = payload.get("profiles", {}).get(profile)
|
|
170
|
+
return SessionProfile.from_dict(raw_profile) if isinstance(raw_profile, dict) else None
|
|
171
|
+
|
|
166
172
|
def get_backend_session(self, profile: str) -> BackendSession | None:
|
|
167
173
|
if profile in self._logged_out_profiles:
|
|
168
174
|
return None
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
from typing import Any
|
|
5
4
|
import os
|
|
5
|
+
from typing import Any
|
|
6
6
|
|
|
7
7
|
from mcp.server.fastmcp import FastMCP
|
|
8
8
|
|
|
@@ -85,7 +85,8 @@ class AuthTools(ToolBase):
|
|
|
85
85
|
persist: bool = False,
|
|
86
86
|
) -> dict[str, Any]:
|
|
87
87
|
"""执行认证与会话相关逻辑。"""
|
|
88
|
-
resolved_base_url, resolved_credential = self.
|
|
88
|
+
resolved_base_url, resolved_credential = self._resolve_auth_inputs(
|
|
89
|
+
profile=profile,
|
|
89
90
|
base_url=base_url,
|
|
90
91
|
credential=credential,
|
|
91
92
|
)
|
|
@@ -306,10 +307,18 @@ class AuthTools(ToolBase):
|
|
|
306
307
|
),
|
|
307
308
|
}
|
|
308
309
|
|
|
309
|
-
def
|
|
310
|
-
|
|
310
|
+
def _resolve_auth_inputs(
|
|
311
|
+
self,
|
|
312
|
+
*,
|
|
313
|
+
profile: str,
|
|
314
|
+
base_url: str | None,
|
|
315
|
+
credential: str | None,
|
|
316
|
+
) -> tuple[str | None, str]:
|
|
317
|
+
"""按显式参数、环境变量、mcporter、profile 的顺序解析认证输入。"""
|
|
311
318
|
normalized_base_url = self._normalize_text(base_url)
|
|
312
319
|
normalized_credential = self._normalize_text(credential)
|
|
320
|
+
if not normalized_credential:
|
|
321
|
+
normalized_credential = self._normalize_text(os.getenv("QINGFLOW_CREDENTIAL"))
|
|
313
322
|
if normalized_base_url and normalized_credential:
|
|
314
323
|
return normalized_base_url, normalized_credential
|
|
315
324
|
|
|
@@ -317,11 +326,13 @@ class AuthTools(ToolBase):
|
|
|
317
326
|
if not normalized_base_url:
|
|
318
327
|
normalized_base_url = self._normalize_text(mcporter_context.get("base_url"))
|
|
319
328
|
if not normalized_base_url:
|
|
320
|
-
normalized_base_url = os.getenv("QINGFLOW_BASE_URL")
|
|
329
|
+
normalized_base_url = self._normalize_text(os.getenv("QINGFLOW_BASE_URL"))
|
|
321
330
|
if not normalized_credential:
|
|
322
331
|
normalized_credential = self._normalize_text(mcporter_context.get("credential"))
|
|
323
332
|
if not normalized_credential:
|
|
324
|
-
|
|
333
|
+
session_profile = self.sessions.peek_profile(profile)
|
|
334
|
+
if session_profile is not None:
|
|
335
|
+
normalized_credential = self._normalize_text(session_profile.credential)
|
|
325
336
|
return normalized_base_url, normalized_credential or ""
|
|
326
337
|
|
|
327
338
|
def _read_mcporter_qingflow_context(self) -> dict[str, str]:
|
|
@@ -355,15 +366,12 @@ class AuthTools(ToolBase):
|
|
|
355
366
|
profile: str,
|
|
356
367
|
session_profile,
|
|
357
368
|
) -> bool:
|
|
358
|
-
backend_session = self.sessions.get_backend_session(profile) if session_profile is not None else None
|
|
359
369
|
base_url = session_profile.base_url if session_profile is not None else None
|
|
360
|
-
credential = backend_session.credential if backend_session is not None else None
|
|
361
370
|
persist = bool(session_profile.persisted) if session_profile is not None else False
|
|
362
371
|
try:
|
|
363
372
|
self.auth_use_credential(
|
|
364
373
|
profile=profile,
|
|
365
374
|
base_url=base_url,
|
|
366
|
-
credential=credential,
|
|
367
375
|
persist=persist,
|
|
368
376
|
)
|
|
369
377
|
except (QingflowApiError, RuntimeError):
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
from contextvars import ContextVar
|
|
4
5
|
from typing import Any, Callable, TypeVar
|
|
5
6
|
|
|
6
7
|
from ..backend_client import BackendRequestContext, BackendClient
|
|
7
|
-
from ..cloud_context import current_cloud_context
|
|
8
|
+
from ..cloud_context import current_call_platform, current_cloud_context
|
|
8
9
|
from ..config import (
|
|
9
10
|
get_credit_meter_enabled,
|
|
10
11
|
get_credit_usage_base_url,
|
|
@@ -29,6 +30,7 @@ class ToolBase:
|
|
|
29
30
|
if cloud_context is not None:
|
|
30
31
|
return cloud_context.as_tool_context(profile)
|
|
31
32
|
|
|
33
|
+
self._ensure_environment_credential_matches(profile)
|
|
32
34
|
session_profile = self.sessions.get_profile(profile)
|
|
33
35
|
if session_profile is None:
|
|
34
36
|
raise QingflowApiError.auth_required(profile)
|
|
@@ -151,50 +153,106 @@ class ToolBase:
|
|
|
151
153
|
if not credential:
|
|
152
154
|
return False
|
|
153
155
|
try:
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
session_profile
|
|
157
|
-
"/mcp/auth/context",
|
|
158
|
-
json_body={"credential": credential},
|
|
159
|
-
qf_version=backend_session.qf_version,
|
|
160
|
-
)
|
|
161
|
-
payload = self._unwrap_context_payload(response.data)
|
|
162
|
-
token = self._normalize_text(payload.get("token"))
|
|
163
|
-
ws_id = self._coerce_positive_int(payload.get("wsId"))
|
|
164
|
-
uid = self._coerce_positive_int(payload.get("uid")) or session_profile.uid
|
|
165
|
-
if not token or ws_id is None:
|
|
166
|
-
return False
|
|
167
|
-
backend_qf_version = self._normalize_text(payload.get("qfVersion")) or response.qf_response_version
|
|
168
|
-
qf_version = backend_qf_version if backend_qf_version is not None else backend_session.qf_version
|
|
169
|
-
qf_version_source = (
|
|
170
|
-
"backend_response"
|
|
171
|
-
if backend_qf_version is not None
|
|
172
|
-
else backend_session.qf_version_source
|
|
173
|
-
)
|
|
174
|
-
base_url = self._normalize_text(payload.get("baseUrl")) or session_profile.base_url
|
|
175
|
-
ws_name = self._normalize_text(payload.get("wsName")) or session_profile.selected_ws_name
|
|
176
|
-
self.sessions.save_session(
|
|
177
|
-
profile=profile,
|
|
178
|
-
base_url=base_url,
|
|
179
|
-
qf_version=qf_version,
|
|
180
|
-
qf_version_source=qf_version_source,
|
|
181
|
-
token=token,
|
|
182
|
-
login_token=None,
|
|
156
|
+
self._exchange_session_from_credential(
|
|
157
|
+
profile,
|
|
158
|
+
session_profile=session_profile,
|
|
183
159
|
credential=credential,
|
|
184
|
-
uid=uid,
|
|
185
|
-
email=self._normalize_text(payload.get("email")) or session_profile.email,
|
|
186
|
-
nick_name=(
|
|
187
|
-
self._normalize_text(payload.get("nickName"))
|
|
188
|
-
or self._normalize_text(payload.get("displayName"))
|
|
189
|
-
or self._normalize_text(payload.get("name"))
|
|
190
|
-
or session_profile.nick_name
|
|
191
|
-
),
|
|
192
|
-
persist=session_profile.persisted,
|
|
193
160
|
)
|
|
194
|
-
self.sessions.select_workspace(profile, ws_id=ws_id, ws_name=ws_name)
|
|
195
|
-
return True
|
|
196
161
|
except QingflowApiError:
|
|
197
162
|
return False
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
def _ensure_environment_credential_matches(self, profile: str) -> None:
|
|
166
|
+
environment_credential = self._normalize_text(os.getenv("QINGFLOW_CREDENTIAL"))
|
|
167
|
+
if environment_credential is None:
|
|
168
|
+
return
|
|
169
|
+
session_profile = self.sessions.peek_profile(profile)
|
|
170
|
+
if session_profile is None:
|
|
171
|
+
return
|
|
172
|
+
if self._normalize_text(session_profile.credential) == environment_credential:
|
|
173
|
+
return
|
|
174
|
+
try:
|
|
175
|
+
self._exchange_session_from_credential(
|
|
176
|
+
profile,
|
|
177
|
+
session_profile=session_profile,
|
|
178
|
+
credential=environment_credential,
|
|
179
|
+
preserve_existing_metadata=False,
|
|
180
|
+
)
|
|
181
|
+
except QingflowApiError as error:
|
|
182
|
+
raise QingflowApiError(
|
|
183
|
+
category="auth",
|
|
184
|
+
message=(
|
|
185
|
+
f"QINGFLOW_CREDENTIAL changed for profile '{profile}', "
|
|
186
|
+
"but the session could not be refreshed."
|
|
187
|
+
),
|
|
188
|
+
backend_code=error.backend_code,
|
|
189
|
+
request_id=error.request_id,
|
|
190
|
+
http_status=error.http_status,
|
|
191
|
+
details={
|
|
192
|
+
"reason": "environment_credential_changed",
|
|
193
|
+
},
|
|
194
|
+
) from error
|
|
195
|
+
|
|
196
|
+
def _exchange_session_from_credential(
|
|
197
|
+
self,
|
|
198
|
+
profile: str,
|
|
199
|
+
*,
|
|
200
|
+
session_profile: SessionProfile,
|
|
201
|
+
credential: str,
|
|
202
|
+
preserve_existing_metadata: bool = True,
|
|
203
|
+
) -> None:
|
|
204
|
+
response = self.backend.public_request_with_meta(
|
|
205
|
+
"POST",
|
|
206
|
+
session_profile.base_url,
|
|
207
|
+
"/mcp/auth/context",
|
|
208
|
+
json_body={"credential": credential},
|
|
209
|
+
qf_version=session_profile.qf_version,
|
|
210
|
+
)
|
|
211
|
+
payload = self._unwrap_context_payload(response.data)
|
|
212
|
+
token = self._normalize_text(payload.get("token"))
|
|
213
|
+
ws_id = self._coerce_positive_int(payload.get("wsId"))
|
|
214
|
+
response_uid = self._coerce_positive_int(payload.get("uid"))
|
|
215
|
+
if not token or ws_id is None or (response_uid is None and not preserve_existing_metadata):
|
|
216
|
+
raise QingflowApiError(
|
|
217
|
+
category="auth",
|
|
218
|
+
message="Credential context did not return a valid user session and workspace.",
|
|
219
|
+
request_id=response.request_id,
|
|
220
|
+
http_status=response.http_status,
|
|
221
|
+
)
|
|
222
|
+
uid = response_uid or session_profile.uid
|
|
223
|
+
backend_qf_version = self._normalize_text(payload.get("qfVersion")) or response.qf_response_version
|
|
224
|
+
qf_version = backend_qf_version if backend_qf_version is not None else session_profile.qf_version
|
|
225
|
+
qf_version_source = (
|
|
226
|
+
"backend_response"
|
|
227
|
+
if backend_qf_version is not None
|
|
228
|
+
else session_profile.qf_version_source
|
|
229
|
+
)
|
|
230
|
+
base_url = self._normalize_text(payload.get("baseUrl")) or session_profile.base_url
|
|
231
|
+
ws_name = self._normalize_text(payload.get("wsName"))
|
|
232
|
+
email = self._normalize_text(payload.get("email"))
|
|
233
|
+
nick_name = (
|
|
234
|
+
self._normalize_text(payload.get("nickName"))
|
|
235
|
+
or self._normalize_text(payload.get("displayName"))
|
|
236
|
+
or self._normalize_text(payload.get("name"))
|
|
237
|
+
)
|
|
238
|
+
if preserve_existing_metadata:
|
|
239
|
+
ws_name = ws_name or session_profile.selected_ws_name
|
|
240
|
+
email = email or session_profile.email
|
|
241
|
+
nick_name = nick_name or session_profile.nick_name
|
|
242
|
+
self.sessions.save_session(
|
|
243
|
+
profile=profile,
|
|
244
|
+
base_url=base_url,
|
|
245
|
+
qf_version=qf_version,
|
|
246
|
+
qf_version_source=qf_version_source,
|
|
247
|
+
token=token,
|
|
248
|
+
login_token=None,
|
|
249
|
+
credential=credential,
|
|
250
|
+
uid=uid,
|
|
251
|
+
email=email,
|
|
252
|
+
nick_name=nick_name,
|
|
253
|
+
persist=session_profile.persisted,
|
|
254
|
+
)
|
|
255
|
+
self.sessions.select_workspace(profile, ws_id=ws_id, ws_name=ws_name)
|
|
198
256
|
|
|
199
257
|
def _unwrap_context_payload(self, payload: Any) -> dict[str, Any]:
|
|
200
258
|
if not isinstance(payload, dict):
|
|
@@ -265,6 +323,7 @@ class ToolBase:
|
|
|
265
323
|
"scene": "MCP",
|
|
266
324
|
"aiBiz": "MCP",
|
|
267
325
|
"extraInfo": tool_name,
|
|
326
|
+
"callPlatform": current_call_platform.get(),
|
|
268
327
|
},
|
|
269
328
|
)
|
|
270
329
|
|
|
@@ -535,6 +535,10 @@ class TaskContextTools(ToolBase):
|
|
|
535
535
|
task_context=task_context,
|
|
536
536
|
fields=field_updates,
|
|
537
537
|
)
|
|
538
|
+
elif normalized_action == "transfer" and body.get("answers") is None:
|
|
539
|
+
record = task_context.get("record") if isinstance(task_context.get("record"), dict) else {}
|
|
540
|
+
current_answers = record.get("answers") if isinstance(record.get("answers"), list) else []
|
|
541
|
+
prepared_fields = {"merged_answers": current_answers}
|
|
538
542
|
before_apply_status = ((task_context.get("record") or {}).get("apply_status"))
|
|
539
543
|
runtime_baseline = None
|
|
540
544
|
if normalized_action != "save_only":
|
|
@@ -704,6 +708,8 @@ class TaskContextTools(ToolBase):
|
|
|
704
708
|
action_payload["auditFeedback"] = audit_feedback
|
|
705
709
|
if merged_answers is not None:
|
|
706
710
|
action_payload["answers"] = merged_answers
|
|
711
|
+
elif payload.get("answers") is not None:
|
|
712
|
+
action_payload["answers"] = payload["answers"]
|
|
707
713
|
return self._approval_tools.record_transfer(
|
|
708
714
|
profile=profile,
|
|
709
715
|
app_key=app_key,
|