@qingflow-tech/qingflow-cli 1.1.41 → 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 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.41
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.41 qingflow
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.41 qingflow-skills install --agent codex --scope user --copy
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qingflow-tech/qingflow-cli",
3
- "version": "1.1.41",
3
+ "version": "1.1.42",
4
4
  "description": "Human-friendly Qingflow command line interface for auth, record operations, import, tasks, and stable builder flows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "1.1.41"
7
+ version = "1.1.42"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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()
@@ -68,3 +68,8 @@ current_cloud_context: ContextVar[CloudMcpContext | None] = ContextVar(
68
68
  "qingflow_mcp_current_cloud_context",
69
69
  default=None,
70
70
  )
71
+
72
+ current_call_platform: ContextVar[str] = ContextVar(
73
+ "qingflow_mcp_current_call_platform",
74
+ default="",
75
+ )
@@ -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
- credential = self._extract_credential(scope)
73
- if credential is None:
74
- await self._json_response(
75
- send,
76
- 401,
77
- {
78
- "error": "missing_mcp_credential",
79
- "message": "Authorization: Bearer <mcp_credential> is required",
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
- cloud_context = await anyio.to_thread.run_sync(self._broker.resolve, credential)
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
- token = current_cloud_context.set(cloud_context)
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
- current_cloud_context.reset(token)
143
+ current_call_platform.reset(call_platform_token)
144
+ current_cloud_context.reset(context_token)
106
145
 
107
- def _extract_credential(self, scope: dict[str, Any]) -> str | None:
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
- credential = authorization[7:].strip()
115
- return credential or None
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
- broker = CloudAuthBroker.from_config(backend=BackendClient())
180
- return CloudMcpAuthApp(server.streamable_http_app(), broker)
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
 
@@ -5,7 +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
+ from ..cloud_context import current_call_platform, current_cloud_context
9
9
  from ..config import (
10
10
  get_credit_meter_enabled,
11
11
  get_credit_usage_base_url,
@@ -323,6 +323,7 @@ class ToolBase:
323
323
  "scene": "MCP",
324
324
  "aiBiz": "MCP",
325
325
  "extraInfo": tool_name,
326
+ "callPlatform": current_call_platform.get(),
326
327
  },
327
328
  )
328
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,