@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.
Files changed (51) hide show
  1. package/README.md +4 -4
  2. package/package.json +2 -1
  3. package/pyproject.toml +3 -2
  4. package/skills/qingflow-cli/reference/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md +1 -1
  5. package/skills/qingflow-cli/reference/task/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md +1 -1
  6. package/src/qingflow_mcp/app_form_compat.py +115 -0
  7. package/src/qingflow_mcp/app_form_validator.py +199 -0
  8. package/src/qingflow_mcp/builder_facade/app_form.py +2836 -0
  9. package/src/qingflow_mcp/builder_facade/app_form_schema.py +269 -0
  10. package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +37 -0
  11. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +1766 -0
  12. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +76 -0
  13. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +76 -0
  14. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +76 -0
  15. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +223 -0
  16. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +538 -0
  17. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +76 -0
  18. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +76 -0
  19. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +149 -0
  20. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +76 -0
  21. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +76 -0
  22. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +76 -0
  23. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +76 -0
  24. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +92 -0
  25. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +76 -0
  26. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +76 -0
  27. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +338 -0
  28. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +137 -0
  29. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +92 -0
  30. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +92 -0
  31. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +810 -0
  32. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +76 -0
  33. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +713 -0
  34. package/src/qingflow_mcp/builder_facade/service.py +5 -2
  35. package/src/qingflow_mcp/cli/commands/builder.py +91 -28
  36. package/src/qingflow_mcp/cli/json_io.py +21 -2
  37. package/src/qingflow_mcp/cli/main.py +3 -39
  38. package/src/qingflow_mcp/cloud_auth.py +11 -0
  39. package/src/qingflow_mcp/cloud_context.py +5 -0
  40. package/src/qingflow_mcp/cloud_oauth_proxy.py +453 -0
  41. package/src/qingflow_mcp/cloud_redis.py +25 -0
  42. package/src/qingflow_mcp/config.py +44 -0
  43. package/src/qingflow_mcp/public_surface.py +5 -2
  44. package/src/qingflow_mcp/response_trim.py +8 -0
  45. package/src/qingflow_mcp/server_app_builder.py +38 -86
  46. package/src/qingflow_mcp/server_app_user.py +2 -1
  47. package/src/qingflow_mcp/server_cloud.py +245 -22
  48. package/src/qingflow_mcp/tools/ai_builder_tools.py +93 -100
  49. package/src/qingflow_mcp/tools/app_tools.py +46 -65
  50. package/src/qingflow_mcp/tools/base.py +2 -1
  51. 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",