@qingflow-tech/qingflow-cli 1.1.41 → 1.1.43-beta.1
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 +4 -4
- package/package.json +2 -1
- package/pyproject.toml +3 -2
- 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/app_form_compat.py +115 -0
- package/src/qingflow_mcp/app_form_validator.py +199 -0
- package/src/qingflow_mcp/builder_facade/app_form.py +2836 -0
- package/src/qingflow_mcp/builder_facade/app_form_schema.py +269 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +37 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +1766 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +223 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +538 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +149 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +92 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +338 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +137 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +92 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +92 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +810 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +76 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +713 -0
- package/src/qingflow_mcp/builder_facade/service.py +5 -2
- package/src/qingflow_mcp/cli/commands/builder.py +91 -28
- package/src/qingflow_mcp/cli/json_io.py +21 -2
- package/src/qingflow_mcp/cli/main.py +3 -39
- 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 +453 -0
- package/src/qingflow_mcp/cloud_redis.py +25 -0
- package/src/qingflow_mcp/config.py +44 -0
- package/src/qingflow_mcp/public_surface.py +5 -2
- package/src/qingflow_mcp/response_trim.py +8 -0
- package/src/qingflow_mcp/server_app_builder.py +38 -86
- package/src/qingflow_mcp/server_app_user.py +2 -1
- package/src/qingflow_mcp/server_cloud.py +245 -22
- package/src/qingflow_mcp/tools/ai_builder_tools.py +93 -100
- package/src/qingflow_mcp/tools/app_tools.py +46 -65
- package/src/qingflow_mcp/tools/base.py +2 -1
- package/src/qingflow_mcp/tools/task_context_tools.py +6 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .backend_client import BackendClient, BackendRequestContext
|
|
11
|
+
from .cloud_context import CloudMcpContext
|
|
12
|
+
from .config import (
|
|
13
|
+
get_cloud_oauth_resource_url,
|
|
14
|
+
get_cloud_oauth_service_base_url,
|
|
15
|
+
get_default_base_url,
|
|
16
|
+
get_default_qf_version,
|
|
17
|
+
get_timeout_seconds,
|
|
18
|
+
normalize_base_url,
|
|
19
|
+
)
|
|
20
|
+
from .errors import QingflowApiError, is_auth_like_error
|
|
21
|
+
from .json_types import JSONObject
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"
|
|
25
|
+
AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"
|
|
26
|
+
OAUTH_ENDPOINT_PATHS = {
|
|
27
|
+
"authorization_endpoint": "/oauth/authorize",
|
|
28
|
+
"token_endpoint": "/oauth/token",
|
|
29
|
+
"registration_endpoint": "/oauth/register",
|
|
30
|
+
}
|
|
31
|
+
FORWARDED_REQUEST_HEADERS = {"accept", "content-type"}
|
|
32
|
+
HOP_BY_HOP_RESPONSE_HEADERS = {
|
|
33
|
+
"connection",
|
|
34
|
+
# httpx decodes compressed response bodies before exposing response.content.
|
|
35
|
+
"content-encoding",
|
|
36
|
+
"keep-alive",
|
|
37
|
+
"proxy-authenticate",
|
|
38
|
+
"proxy-authorization",
|
|
39
|
+
"te",
|
|
40
|
+
"trailer",
|
|
41
|
+
"transfer-encoding",
|
|
42
|
+
"upgrade",
|
|
43
|
+
"date",
|
|
44
|
+
"server",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class CloudOAuthProxyConfig:
|
|
50
|
+
service_base_url: str
|
|
51
|
+
resource_url: str
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_config(cls) -> "CloudOAuthProxyConfig | None":
|
|
55
|
+
resource_url = get_cloud_oauth_resource_url()
|
|
56
|
+
if resource_url is None:
|
|
57
|
+
return None
|
|
58
|
+
service_base_url = get_cloud_oauth_service_base_url()
|
|
59
|
+
if service_base_url is None:
|
|
60
|
+
raise QingflowApiError.config_error(
|
|
61
|
+
"cloud.oauth.service_base_url or default_base_url is required when OAuth is enabled"
|
|
62
|
+
)
|
|
63
|
+
return cls(
|
|
64
|
+
service_base_url=service_base_url,
|
|
65
|
+
resource_url=_validate_resource_url(resource_url),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def resource_metadata_url(self) -> str:
|
|
70
|
+
return f"{self.metadata_issuer_url}{PROTECTED_RESOURCE_METADATA_PATH}"
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def metadata_issuer_url(self) -> str:
|
|
74
|
+
parsed = urlsplit(self.resource_url)
|
|
75
|
+
return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
|
|
76
|
+
|
|
77
|
+
def backend_metadata_path(self, request_path: str) -> str | None:
|
|
78
|
+
if request_path == PROTECTED_RESOURCE_METADATA_PATH or request_path.startswith(
|
|
79
|
+
f"{PROTECTED_RESOURCE_METADATA_PATH}/"
|
|
80
|
+
):
|
|
81
|
+
return PROTECTED_RESOURCE_METADATA_PATH
|
|
82
|
+
if request_path == AUTHORIZATION_SERVER_METADATA_PATH or request_path.startswith(
|
|
83
|
+
f"{AUTHORIZATION_SERVER_METADATA_PATH}/"
|
|
84
|
+
):
|
|
85
|
+
return AUTHORIZATION_SERVER_METADATA_PATH
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def backend_oauth_endpoint_path(self, request_path: str) -> str | None:
|
|
89
|
+
if request_path in OAUTH_ENDPOINT_PATHS.values():
|
|
90
|
+
return request_path
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class OAuthProxyResponse:
|
|
96
|
+
status_code: int
|
|
97
|
+
headers: tuple[tuple[bytes, bytes], ...]
|
|
98
|
+
body: bytes
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class OAuthServiceProxy:
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
config: CloudOAuthProxyConfig,
|
|
105
|
+
*,
|
|
106
|
+
client: httpx.Client | None = None,
|
|
107
|
+
) -> None:
|
|
108
|
+
self.config = config
|
|
109
|
+
self._owns_client = client is None
|
|
110
|
+
self._client = client or httpx.Client(
|
|
111
|
+
timeout=get_timeout_seconds(),
|
|
112
|
+
follow_redirects=False,
|
|
113
|
+
trust_env=False,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def close(self) -> None:
|
|
117
|
+
if self._owns_client:
|
|
118
|
+
self._client.close()
|
|
119
|
+
|
|
120
|
+
def request_metadata(
|
|
121
|
+
self,
|
|
122
|
+
method: str,
|
|
123
|
+
backend_path: str,
|
|
124
|
+
query_string: bytes = b"",
|
|
125
|
+
) -> OAuthProxyResponse:
|
|
126
|
+
url = f"{self.config.service_base_url}/{backend_path.lstrip('/')}"
|
|
127
|
+
if query_string:
|
|
128
|
+
url = f"{url}?{query_string.decode('latin1')}"
|
|
129
|
+
try:
|
|
130
|
+
response = self._client.request(
|
|
131
|
+
method.upper(),
|
|
132
|
+
url,
|
|
133
|
+
headers={"Accept": "application/json"},
|
|
134
|
+
)
|
|
135
|
+
except httpx.RequestError as error:
|
|
136
|
+
raise QingflowApiError(
|
|
137
|
+
category="network",
|
|
138
|
+
message=f"OAuth service request failed: {error}",
|
|
139
|
+
) from error
|
|
140
|
+
|
|
141
|
+
headers = tuple(
|
|
142
|
+
(name.encode("latin1"), value.encode("latin1"))
|
|
143
|
+
for name, value in response.headers.multi_items()
|
|
144
|
+
if name.lower() not in HOP_BY_HOP_RESPONSE_HEADERS
|
|
145
|
+
and name.lower() != "content-length"
|
|
146
|
+
)
|
|
147
|
+
return OAuthProxyResponse(
|
|
148
|
+
status_code=response.status_code,
|
|
149
|
+
headers=headers,
|
|
150
|
+
body=self._rewrite_metadata_body(backend_path, response),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def request_oauth_endpoint(
|
|
154
|
+
self,
|
|
155
|
+
method: str,
|
|
156
|
+
backend_path: str,
|
|
157
|
+
query_string: bytes = b"",
|
|
158
|
+
body: bytes = b"",
|
|
159
|
+
headers: tuple[tuple[bytes, bytes], ...] = (),
|
|
160
|
+
) -> OAuthProxyResponse:
|
|
161
|
+
url = f"{self.config.service_base_url}/{backend_path.lstrip('/')}"
|
|
162
|
+
if query_string:
|
|
163
|
+
url = f"{url}?{query_string.decode('latin1')}"
|
|
164
|
+
forwarded_headers = {
|
|
165
|
+
name.decode("latin1"): value.decode("latin1")
|
|
166
|
+
for name, value in headers
|
|
167
|
+
if name.decode("latin1").lower() in FORWARDED_REQUEST_HEADERS
|
|
168
|
+
}
|
|
169
|
+
try:
|
|
170
|
+
response = self._client.request(
|
|
171
|
+
method.upper(),
|
|
172
|
+
url,
|
|
173
|
+
content=body,
|
|
174
|
+
headers=forwarded_headers,
|
|
175
|
+
)
|
|
176
|
+
except httpx.RequestError as error:
|
|
177
|
+
raise QingflowApiError(
|
|
178
|
+
category="network",
|
|
179
|
+
message=f"OAuth service request failed: {error}",
|
|
180
|
+
) from error
|
|
181
|
+
|
|
182
|
+
response_headers = tuple(
|
|
183
|
+
(name.encode("latin1"), value.encode("latin1"))
|
|
184
|
+
for name, value in response.headers.multi_items()
|
|
185
|
+
if name.lower() not in HOP_BY_HOP_RESPONSE_HEADERS
|
|
186
|
+
and name.lower() != "content-length"
|
|
187
|
+
)
|
|
188
|
+
return OAuthProxyResponse(
|
|
189
|
+
status_code=response.status_code,
|
|
190
|
+
headers=response_headers,
|
|
191
|
+
body=response.content,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def _rewrite_metadata_body(
|
|
195
|
+
self,
|
|
196
|
+
backend_path: str,
|
|
197
|
+
response: httpx.Response,
|
|
198
|
+
) -> bytes:
|
|
199
|
+
if not 200 <= response.status_code < 300 or not response.content:
|
|
200
|
+
return response.content
|
|
201
|
+
try:
|
|
202
|
+
payload = json.loads(response.content)
|
|
203
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
204
|
+
return response.content
|
|
205
|
+
if not isinstance(payload, dict):
|
|
206
|
+
return response.content
|
|
207
|
+
|
|
208
|
+
changed = False
|
|
209
|
+
issuer = self.config.metadata_issuer_url
|
|
210
|
+
if backend_path == PROTECTED_RESOURCE_METADATA_PATH:
|
|
211
|
+
for field_name in ("authorization_servers", "authorizationServers"):
|
|
212
|
+
if field_name in payload:
|
|
213
|
+
payload[field_name] = [issuer]
|
|
214
|
+
changed = True
|
|
215
|
+
elif backend_path == AUTHORIZATION_SERVER_METADATA_PATH:
|
|
216
|
+
if "issuer" in payload:
|
|
217
|
+
payload["issuer"] = issuer
|
|
218
|
+
changed = True
|
|
219
|
+
for field_name, endpoint_path in OAUTH_ENDPOINT_PATHS.items():
|
|
220
|
+
if field_name in payload:
|
|
221
|
+
payload[field_name] = f"{issuer}{endpoint_path}"
|
|
222
|
+
changed = True
|
|
223
|
+
|
|
224
|
+
if not changed:
|
|
225
|
+
return response.content
|
|
226
|
+
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode(
|
|
227
|
+
"utf-8"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass(slots=True)
|
|
232
|
+
class OAuthAccessTokenResolver:
|
|
233
|
+
backend: BackendClient
|
|
234
|
+
base_url: str
|
|
235
|
+
qf_version: str | None = None
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def from_config(cls, backend: BackendClient) -> "OAuthAccessTokenResolver":
|
|
239
|
+
base_url = get_default_base_url()
|
|
240
|
+
if base_url is None:
|
|
241
|
+
raise QingflowApiError.config_error(
|
|
242
|
+
"default_base_url is required for OAuth access token requests"
|
|
243
|
+
)
|
|
244
|
+
return cls(
|
|
245
|
+
backend=backend,
|
|
246
|
+
base_url=base_url,
|
|
247
|
+
qf_version=get_default_qf_version(),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def resolve(self, access_token: str) -> CloudMcpContext:
|
|
251
|
+
normalized_token = str(access_token or "").strip()
|
|
252
|
+
if not normalized_token:
|
|
253
|
+
raise QingflowApiError(category="auth", message="OAuth access token is required")
|
|
254
|
+
|
|
255
|
+
user_info, workspace, response_qf_version = self._load_backend_context(
|
|
256
|
+
normalized_token
|
|
257
|
+
)
|
|
258
|
+
uid = self._coerce_positive_int(user_info.get("uid"))
|
|
259
|
+
ws_id = self._coerce_positive_int(workspace.get("wsId"))
|
|
260
|
+
if uid is None or ws_id is None:
|
|
261
|
+
raise QingflowApiError(
|
|
262
|
+
category="auth",
|
|
263
|
+
message="OAuth access token did not resolve a valid user and workspace",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
workspace_qf_version, workspace_qf_version_source = (
|
|
267
|
+
self._resolve_workspace_qf_version(workspace)
|
|
268
|
+
)
|
|
269
|
+
qf_version = workspace_qf_version or response_qf_version or self.qf_version
|
|
270
|
+
qf_version_source = (
|
|
271
|
+
workspace_qf_version_source
|
|
272
|
+
if workspace_qf_version
|
|
273
|
+
else "backend_response"
|
|
274
|
+
if response_qf_version
|
|
275
|
+
else "default_config"
|
|
276
|
+
if self.qf_version
|
|
277
|
+
else None
|
|
278
|
+
)
|
|
279
|
+
return CloudMcpContext(
|
|
280
|
+
credential_hash="",
|
|
281
|
+
credential="",
|
|
282
|
+
token=normalized_token,
|
|
283
|
+
uid=uid,
|
|
284
|
+
ws_id=ws_id,
|
|
285
|
+
base_url=self.base_url,
|
|
286
|
+
qf_version=qf_version,
|
|
287
|
+
qf_version_source=qf_version_source,
|
|
288
|
+
email=self._normalize_text(user_info.get("email")),
|
|
289
|
+
nick_name=self._normalize_text(
|
|
290
|
+
user_info.get("nickName")
|
|
291
|
+
or user_info.get("displayName")
|
|
292
|
+
or user_info.get("name")
|
|
293
|
+
),
|
|
294
|
+
ws_name=self._normalize_text(
|
|
295
|
+
workspace.get("workspaceName")
|
|
296
|
+
or workspace.get("wsName")
|
|
297
|
+
or workspace.get("remark")
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def _load_backend_context(
|
|
302
|
+
self, access_token: str
|
|
303
|
+
) -> tuple[JSONObject, JSONObject, str | None]:
|
|
304
|
+
first_workspace: JSONObject | None = None
|
|
305
|
+
response_qf_version: str | None = None
|
|
306
|
+
try:
|
|
307
|
+
user_response = self.backend.request_with_meta(
|
|
308
|
+
"GET",
|
|
309
|
+
self._request_context(access_token, None),
|
|
310
|
+
"/user",
|
|
311
|
+
)
|
|
312
|
+
except QingflowApiError as original_error:
|
|
313
|
+
first_workspace, response_qf_version = self._fetch_first_workspace(
|
|
314
|
+
access_token
|
|
315
|
+
)
|
|
316
|
+
if first_workspace is None:
|
|
317
|
+
raise original_error
|
|
318
|
+
first_ws_id = self._coerce_positive_int(first_workspace.get("wsId"))
|
|
319
|
+
if first_ws_id is None:
|
|
320
|
+
raise original_error
|
|
321
|
+
user_response = self.backend.request_with_meta(
|
|
322
|
+
"GET",
|
|
323
|
+
self._request_context(
|
|
324
|
+
access_token, first_ws_id, response_qf_version
|
|
325
|
+
),
|
|
326
|
+
"/user",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
user_info = self._as_object(user_response.data)
|
|
330
|
+
if user_info is None:
|
|
331
|
+
raise QingflowApiError(
|
|
332
|
+
category="auth", message="OAuth access token did not resolve user info"
|
|
333
|
+
)
|
|
334
|
+
response_qf_version = user_response.qf_response_version or response_qf_version
|
|
335
|
+
|
|
336
|
+
workspace = self._as_object(user_info.get("lastWsInfo")) or first_workspace
|
|
337
|
+
if workspace is None:
|
|
338
|
+
workspace, workspace_response_version = self._fetch_first_workspace(
|
|
339
|
+
access_token
|
|
340
|
+
)
|
|
341
|
+
response_qf_version = workspace_response_version or response_qf_version
|
|
342
|
+
if workspace is None:
|
|
343
|
+
raise QingflowApiError(
|
|
344
|
+
category="auth",
|
|
345
|
+
message="OAuth access token user has no accessible workspace",
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
ws_id = self._coerce_positive_int(workspace.get("wsId"))
|
|
349
|
+
if ws_id is None:
|
|
350
|
+
raise QingflowApiError(
|
|
351
|
+
category="auth",
|
|
352
|
+
message="OAuth access token user has no valid workspace",
|
|
353
|
+
)
|
|
354
|
+
if self._resolve_workspace_qf_version(workspace)[0] is None:
|
|
355
|
+
try:
|
|
356
|
+
workspace_response = self.backend.request_with_meta(
|
|
357
|
+
"GET",
|
|
358
|
+
self._request_context(access_token, ws_id, response_qf_version),
|
|
359
|
+
f"/user/workspace/{ws_id}",
|
|
360
|
+
)
|
|
361
|
+
workspace = self._as_object(workspace_response.data) or workspace
|
|
362
|
+
response_qf_version = (
|
|
363
|
+
workspace_response.qf_response_version or response_qf_version
|
|
364
|
+
)
|
|
365
|
+
except QingflowApiError as error:
|
|
366
|
+
if is_auth_like_error(error):
|
|
367
|
+
raise
|
|
368
|
+
return user_info, workspace, response_qf_version
|
|
369
|
+
|
|
370
|
+
def _fetch_first_workspace(
|
|
371
|
+
self, access_token: str
|
|
372
|
+
) -> tuple[JSONObject | None, str | None]:
|
|
373
|
+
response = self.backend.request_with_meta(
|
|
374
|
+
"POST",
|
|
375
|
+
self._request_context(access_token, None),
|
|
376
|
+
"/user/workspaceList/pageQuery",
|
|
377
|
+
json_body={"pageNum": 1, "pageSize": 1},
|
|
378
|
+
)
|
|
379
|
+
page = self._as_object(response.data)
|
|
380
|
+
workspaces = page.get("list") if page else None
|
|
381
|
+
first = workspaces[0] if isinstance(workspaces, list) and workspaces else None
|
|
382
|
+
return self._as_object(first), response.qf_response_version
|
|
383
|
+
|
|
384
|
+
def _request_context(
|
|
385
|
+
self,
|
|
386
|
+
access_token: str,
|
|
387
|
+
ws_id: int | None,
|
|
388
|
+
qf_version: str | None = None,
|
|
389
|
+
) -> BackendRequestContext:
|
|
390
|
+
return BackendRequestContext(
|
|
391
|
+
base_url=self.base_url,
|
|
392
|
+
token=access_token,
|
|
393
|
+
ws_id=ws_id,
|
|
394
|
+
qf_version=qf_version or self.qf_version,
|
|
395
|
+
qf_version_source=(
|
|
396
|
+
"backend_response"
|
|
397
|
+
if qf_version
|
|
398
|
+
else "default_config"
|
|
399
|
+
if self.qf_version
|
|
400
|
+
else None
|
|
401
|
+
),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
@staticmethod
|
|
405
|
+
def _as_object(value: Any) -> JSONObject | None:
|
|
406
|
+
return value if isinstance(value, dict) else None
|
|
407
|
+
|
|
408
|
+
@classmethod
|
|
409
|
+
def _resolve_workspace_qf_version(
|
|
410
|
+
cls, workspace: JSONObject
|
|
411
|
+
) -> tuple[str | None, str | None]:
|
|
412
|
+
qf_version = cls._normalize_text(workspace.get("qfVersion"))
|
|
413
|
+
if qf_version is not None:
|
|
414
|
+
return qf_version, "workspace_qf_version"
|
|
415
|
+
system_version = cls._normalize_text(workspace.get("systemVersion"))
|
|
416
|
+
if system_version is not None:
|
|
417
|
+
return system_version, "workspace_system_version"
|
|
418
|
+
return None, None
|
|
419
|
+
|
|
420
|
+
@staticmethod
|
|
421
|
+
def _normalize_text(value: Any) -> str | None:
|
|
422
|
+
if value is None:
|
|
423
|
+
return None
|
|
424
|
+
text = str(value).strip()
|
|
425
|
+
return text or None
|
|
426
|
+
|
|
427
|
+
@staticmethod
|
|
428
|
+
def _coerce_positive_int(value: Any) -> int | None:
|
|
429
|
+
if isinstance(value, bool) or value is None:
|
|
430
|
+
return None
|
|
431
|
+
try:
|
|
432
|
+
parsed = int(value)
|
|
433
|
+
except (TypeError, ValueError):
|
|
434
|
+
return None
|
|
435
|
+
return parsed if parsed > 0 else None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _validate_resource_url(value: str) -> str:
|
|
439
|
+
parsed = urlsplit(value)
|
|
440
|
+
is_loopback_http = parsed.scheme == "http" and parsed.hostname in {
|
|
441
|
+
"localhost",
|
|
442
|
+
"127.0.0.1",
|
|
443
|
+
"::1",
|
|
444
|
+
}
|
|
445
|
+
if not parsed.netloc or (parsed.scheme != "https" and not is_loopback_http):
|
|
446
|
+
raise QingflowApiError.config_error(
|
|
447
|
+
"cloud.oauth.resource_url must use HTTPS (HTTP is allowed for loopback)"
|
|
448
|
+
)
|
|
449
|
+
if parsed.query or parsed.fragment or parsed.username or parsed.password:
|
|
450
|
+
raise QingflowApiError.config_error(
|
|
451
|
+
"cloud.oauth.resource_url must not contain user info, query parameters, or a fragment"
|
|
452
|
+
)
|
|
453
|
+
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",
|
|
@@ -142,6 +142,11 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
142
142
|
PublicToolSpec(BUILDER_DOMAIN, "role_create", ("role_create",), ("builder", "role", "create"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
143
143
|
PublicToolSpec(BUILDER_DOMAIN, "app_release_edit_lock_if_mine", ("app_release_edit_lock_if_mine",), ("builder", "app", "release-edit-lock-if-mine"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
144
144
|
PublicToolSpec(BUILDER_DOMAIN, "app_resolve", ("app_resolve",), ("builder", "app", "resolve"), has_contract=True, cli_show_effective_context=True),
|
|
145
|
+
PublicToolSpec(BUILDER_DOMAIN, "app_form_schema", ("app_form_schema",), ("builder", "app-form", "schema")),
|
|
146
|
+
PublicToolSpec(BUILDER_DOMAIN, "app_form_validate", (), ("builder", "app-form", "validate"), mcp_public=False),
|
|
147
|
+
PublicToolSpec(BUILDER_DOMAIN, "app_form_get", ("app_form_get",), ("builder", "app-form", "get"), cli_show_effective_context=True),
|
|
148
|
+
PublicToolSpec(BUILDER_DOMAIN, "app_form_apply", ("app_form_apply",), ("builder", "app-form", "apply"), cli_show_effective_context=True, cli_context_write=True),
|
|
149
|
+
PublicToolSpec(BUILDER_DOMAIN, "app_delete", ("app_delete",), ("builder", "app", "delete"), cli_show_effective_context=True, cli_context_write=True),
|
|
145
150
|
PublicToolSpec(BUILDER_DOMAIN, "button_style_catalog_get", ("button_style_catalog_get",), ("builder", "button", "catalog"), has_contract=True, cli_show_effective_context=True),
|
|
146
151
|
PublicToolSpec(BUILDER_DOMAIN, "app_custom_buttons_apply", ("app_custom_buttons_apply",), ("builder", "button", "apply"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
147
152
|
PublicToolSpec(BUILDER_DOMAIN, "app_associated_resources_apply", ("app_associated_resources_apply",), ("builder", "associated-resource", "apply"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
@@ -158,8 +163,6 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
158
163
|
PublicToolSpec(BUILDER_DOMAIN, "portal_get", ("portal_get",), ("builder", "portal", "get"), has_contract=True, cli_show_effective_context=True),
|
|
159
164
|
PublicToolSpec(BUILDER_DOMAIN, "view_get", ("view_get",), ("builder", "view", "get"), has_contract=True, cli_show_effective_context=True),
|
|
160
165
|
PublicToolSpec(BUILDER_DOMAIN, "chart_get", ("chart_get",), ("builder", "chart", "get"), has_contract=True, cli_show_effective_context=True),
|
|
161
|
-
PublicToolSpec(BUILDER_DOMAIN, "app_schema_apply", ("app_schema_apply",), ("builder", "schema", "apply"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
162
|
-
PublicToolSpec(BUILDER_DOMAIN, "app_layout_apply", ("app_layout_apply",), ("builder", "layout", "apply"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
163
166
|
PublicToolSpec(BUILDER_DOMAIN, "app_flow_get_schema", ("app_flow_get_schema",), ("builder", "flow", "schema"), has_contract=True, cli_show_effective_context=True),
|
|
164
167
|
PublicToolSpec(BUILDER_DOMAIN, "app_flow_get", ("app_flow_get",), ("builder", "flow", "get"), has_contract=True, cli_show_effective_context=True),
|
|
165
168
|
PublicToolSpec(BUILDER_DOMAIN, "app_flow_apply", ("app_flow_apply",), ("builder", "flow", "apply"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
@@ -1190,6 +1190,10 @@ _register_policy(
|
|
|
1190
1190
|
"role_create",
|
|
1191
1191
|
"app_release_edit_lock_if_mine",
|
|
1192
1192
|
"app_resolve",
|
|
1193
|
+
"app_form_schema",
|
|
1194
|
+
"app_form_get",
|
|
1195
|
+
"app_form_apply",
|
|
1196
|
+
"app_delete",
|
|
1193
1197
|
"button_style_catalog_get",
|
|
1194
1198
|
"app_custom_buttons_apply",
|
|
1195
1199
|
"app_associated_resources_apply",
|
|
@@ -1198,7 +1202,11 @@ _register_policy(
|
|
|
1198
1202
|
"app_get_layout",
|
|
1199
1203
|
"app_get_views",
|
|
1200
1204
|
"app_get_flow",
|
|
1205
|
+
"app_flow_get_schema",
|
|
1206
|
+
"app_flow_get",
|
|
1201
1207
|
"app_get_charts",
|
|
1208
|
+
"app_get_buttons",
|
|
1209
|
+
"app_get_associated_resources",
|
|
1202
1210
|
"app_schema_apply",
|
|
1203
1211
|
"app_layout_apply",
|
|
1204
1212
|
"app_flow_apply",
|