@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
@@ -37,14 +37,14 @@ def build_builder_server() -> FastMCP:
37
37
  "Use solution_install when the user explicitly wants to install a packaged solution/template by solution_key, optionally copying bundled demo data. "
38
38
  "Use package_list to find visible app packages by keyword and package_get to read package detail before editing; if creating or updating an app package may be appropriate, use package_apply with explicit user intent; otherwise use app_resolve to locate app resources, "
39
39
  "Use workspace_icon_catalog_get before creating app packages, apps, or portals when supported icon/color candidates are needed; new workspace resources require explicit non-template icon + color, and the CLI validates choices without inferring business defaults. "
40
- "app_get as the default app map read, then app_get_fields/app_repair_code_blocks/app_get_layout/app_get_views/app_get_flow/app_get_charts/portal_list/portal_get/view_get/chart_get for focused configuration reads, "
40
+ "app_form_schema plus app_form_get for versioned form work, using being_draft=true only for editing and the published default for relation or record consumers; use app_get as the default non-form app map read, then app_get_fields/app_repair_code_blocks/app_get_layout/app_get_views/app_get_flow/app_get_charts/portal_list/portal_get/view_get/chart_get for focused compatibility or non-form reads, "
41
41
  "member_search/role_search/role_create when workflow assignees must come from the directory or role catalog, preferring roles over explicit members unless the user explicitly names members, "
42
- "then app_schema_apply/app_layout_apply/app_flow_apply/app_views_apply/app_associated_resources_apply/app_charts_apply/portal_apply/portal_delete to execute normalized patches; app_custom_buttons_apply is maintenance-only for explicit standalone button work outside normal view design. These apply/delete tools perform basic validation, normalization, writes, and readback verification where applicable. Schema/layout/views noop requests skip publish, app_custom_buttons_apply and app_associated_resources_apply publish after at least one write succeeds and expose no draft-only parameter, charts are immediate-live without publish and resolve targets by chart_id first then exact unique chart name, portal updates use replace semantics only when sections are supplied and edit-mode base-info-only updates may omit sections, portal delete separates DELETE execution from readback verification, portal pc layout is a 24-column grid and mobile is a 6-column grid so omit position or use layout_preset when unsure, publish=false only guarantees draft/base-info updates for tools that still expose that parameter, and flow should use publish=false whenever you only want draft/precheck behavior. "
42
+ "then app_form_apply for complete app form targets, explicit app_delete for application deletion, and app_flow_apply/app_views_apply/app_associated_resources_apply/app_charts_apply/portal_apply/portal_delete for non-form patches. app_custom_buttons_apply is maintenance-only for explicit standalone button work outside normal view design. These apply/delete tools perform validation, normalization, writes, and readback verification where applicable. AppForm publishes only after a draft change or when the published form lags behind; views noop requests skip publish, app_custom_buttons_apply and app_associated_resources_apply publish after at least one write succeeds and expose no draft-only parameter, charts are immediate-live without publish and resolve targets by chart_id first then exact unique chart name, portal updates use replace semantics only when sections are supplied and edit-mode base-info-only updates may omit sections, portal delete separates DELETE execution from readback verification, portal pc layout is a 24-column grid and mobile is a 6-column grid so omit position or use layout_preset when unsure, publish=false only guarantees draft/base-info updates for tools that still expose that parameter, and flow should use publish=false whenever you only want draft/precheck behavior. "
43
43
  "Builder apply/write outputs include schema_version, operation, summary, and resources[]; use resources[].id/key/name/ids/parent as the stable UI and agent display entry, and keep legacy fields such as field_diff/views_diff/chart_results only for compatibility or troubleshooting. "
44
44
  "For existing object parameter replacement, prefer patch_views, patch_buttons, patch_resources, and patch_charts with set/unset; the tool reads current config and full-saves internally, while upsert_* is for creation or full target configuration and should not be used as an incomplete partial update. "
45
45
  "For builder delete/remove apply results, separate delete execution from readback verification: after DELETE is sent, resources expose delete_executed, readback_status, and safe_to_retry_delete=false. If readback_status is unavailable or still_exists, do not blindly repeat the delete; confirm later with app_get/view_get/chart_get or the relevant apply readback. Views/buttons use single-item readback; associated resources use one app-level resource-pool readback because there is no confirmed single-item GET. "
46
46
  "For builder write/apply results, separate write execution from final readback verification: status=partial_success with write_executed=true and safe_to_retry=false means the write was sent or succeeded, while final readback, publish verification, or metadata confirmation is pending or permission-restricted. Report it as written but unverified; do not repeat the same write blindly. "
47
- "For app_schema_apply, configure data title and data cover directly in field JSON with as_data_title=true and as_data_cover=true; data title is required and exactly one field may be marked, while data cover is optional and must be a top-level attachment field. For multi-app creation, pass apps[]/--apps-file on app_schema_apply; each item may have client_key, and relation fields may use target_app_ref to point at another same-call client_key. "
47
+ "For app_form_apply, pin the version from app_form_schema, validate the JSON declaration with the qingflow-app-form Skill command, preserve the complete body and existing IDs from a draft get, and use dataTitle/dataCover on canonical fields; exactly one top-level dataTitle is required for a non-empty body, while dataCover is optional and must be a top-level attachment field. A root appKey means update and may include packageId; no appKey means create and requires packageId plus spec.appName, spec.icon, and spec.color selected from workspace_icon_catalog_get, with template forbidden. "
48
48
  "For app_views_apply, keep fixed saved filters in filters and configure the frontend query panel separately with query_conditions; query_conditions.rows is a matrix of field names compiled to backend queryCondition queIds. New views default associated report/view display to visible with limit_type=all; existing views preserve their current associated display unless associated_resources is explicitly patched. View writes use ViewManagementAuth (beingViewManageStatus), falling back to DataManageAuth when advanced app permissions are not enabled. "
49
49
  "For ordinary business buttons, declare them on the target view with app_views_apply.views[].action_buttons; app_views_apply writes the view first, then creates/binds the button. Use app_custom_buttons_apply only when the user explicitly asks to maintain existing standalone buttons outside view design, such as custom style/icon changes, cross-view reuse, deleting button bodies, bulk binding reorder, or exact qRobot/wings config. Button body writes require EditAppAuth; view_configs placement writes require ViewManagementAuth, matching the backend viewConfig route. For addData buttons, prefer action_buttons.target_app_key + field_mappings/default_values on the view path; do not ask agents to write raw que_relation unless maintaining a legacy config. field_mappings.source_field accepts source schema fields and supported system fields: 数据ID/row_record_id/apply_id/_id means current record id (-17), 编号/record_number means visible record number (0). To fill a target relation with the current record, map {'source_field': '数据ID', 'target_field': '目标引用字段'}; default_values is only for static constants. View button bindings merge by default; standalone view_configs are only for explicit maintenance. Builder view_key arguments are raw keys from app_get.views[].view_key and must not be prefixed with custom:. "
50
50
  "For BI reports, keep report-body development separate from Qingflow in-app display: use app_charts_apply to create, update, remove, or reorder app-source QingBI chart bodies/configs with dataSourceType=qingflow; chart dimension/metric/filter/query fields must come from app_get_fields.chart_fields, not record schema or form-only fields; dataset BI reports are not created or edited by app_charts_apply yet and should be created in QingBI first, then attached with app_associated_resources_apply using report_source=dataset. "
@@ -180,6 +180,41 @@ def build_builder_server() -> FastMCP:
180
180
  def builder_tool_contract(tool_name: str = "") -> dict:
181
181
  return ai_builder.builder_tool_contract(tool_name=tool_name)
182
182
 
183
+ @server.tool()
184
+ def app_form_schema(
185
+ schema_version: str | None = None,
186
+ field_type: str | None = None,
187
+ ) -> dict:
188
+ return ai_builder.app_form_schema(schema_version=schema_version, field_type=field_type)
189
+
190
+ @server.tool()
191
+ def app_form_get(
192
+ profile: str = DEFAULT_PROFILE,
193
+ app_key: str = "",
194
+ schema_version: str | None = None,
195
+ being_draft: bool = False,
196
+ ) -> dict:
197
+ return ai_builder.app_form_get(
198
+ profile=profile,
199
+ app_key=app_key,
200
+ schema_version=schema_version,
201
+ being_draft=being_draft,
202
+ )
203
+
204
+ @server.tool()
205
+ def app_form_apply(
206
+ profile: str = DEFAULT_PROFILE,
207
+ spec: dict | None = None,
208
+ ) -> dict:
209
+ return ai_builder.app_form_apply(profile=profile, spec=spec or {})
210
+
211
+ @server.tool(description=ai_builder._high_risk_tool_description(operation="delete", target="app configuration"))
212
+ def app_delete(
213
+ profile: str = DEFAULT_PROFILE,
214
+ app_key: str = "",
215
+ ) -> dict:
216
+ return ai_builder.app_delete(profile=profile, app_key=app_key)
217
+
183
218
  @server.tool()
184
219
  def workspace_icon_catalog_get(profile: str = DEFAULT_PROFILE) -> dict:
185
220
  return ai_builder.workspace_icon_catalog_get(profile=profile)
@@ -414,89 +449,6 @@ def build_builder_server() -> FastMCP:
414
449
  def chart_get(profile: str = DEFAULT_PROFILE, chart_id: str = "") -> dict:
415
450
  return ai_builder.chart_get(profile=profile, chart_id=chart_id)
416
451
 
417
- @server.tool()
418
- def app_schema_apply(
419
- profile: str = DEFAULT_PROFILE,
420
- app_key: str = "",
421
- package_id: int | None = None,
422
- app_name: str = "",
423
- app_title: str = "",
424
- icon: str = "",
425
- color: str = "",
426
- visibility: dict | None = None,
427
- publish: bool = True,
428
- form: list[dict] | None = None,
429
- add_fields: list[dict] | None = None,
430
- update_fields: list[dict] | None = None,
431
- remove_fields: list[dict] | None = None,
432
- apps: list[dict] | None = None,
433
- ) -> dict:
434
- if apps:
435
- if app_key or app_name or app_title or form or add_fields or update_fields or remove_fields:
436
- return _config_failure(
437
- "app_schema_apply multi-app mode accepts package_id plus apps only.",
438
- fix_hint="Use apps[].form for batch mode, or use the single-app arguments without apps.",
439
- )
440
- if package_id is None:
441
- return _config_failure(
442
- "app_schema_apply multi-app mode requires package_id.",
443
- fix_hint="Pass `package_id` and `apps[].app_name` for new apps, or `apps[].app_key` for existing apps.",
444
- )
445
- return ai_builder.app_schema_apply(
446
- profile=profile,
447
- package_id=package_id,
448
- visibility=visibility,
449
- publish=publish,
450
- form=None,
451
- add_fields=[],
452
- update_fields=[],
453
- remove_fields=[],
454
- apps=apps,
455
- )
456
- has_app_key = bool((app_key or "").strip())
457
- has_app_name = bool((app_name or "").strip())
458
- has_app_title = bool((app_title or "").strip())
459
- has_package_id = package_id is not None
460
- create_by_shape = has_package_id and (has_app_name or has_app_title)
461
- if has_app_key:
462
- if has_package_id:
463
- return _config_failure(
464
- "app_schema_apply edit mode accepts app_key and optional app_name rename only.",
465
- fix_hint="For existing apps, use `app_key` and optionally `app_name`. For create mode, use `package_id + app_name`.",
466
- )
467
- elif not create_by_shape:
468
- return _config_failure(
469
- "app_schema_apply create mode requires package_id and app_name.",
470
- fix_hint="Use `app_key` for existing apps, or pass `package_id + app_name` to create a new app.",
471
- )
472
- return ai_builder.app_schema_apply(
473
- profile=profile,
474
- app_key=app_key,
475
- package_id=package_id,
476
- app_name=app_name,
477
- app_title=app_title,
478
- icon=icon,
479
- color=color,
480
- visibility=visibility,
481
- publish=publish,
482
- form=form or [],
483
- add_fields=add_fields or [],
484
- update_fields=update_fields or [],
485
- remove_fields=remove_fields or [],
486
- apps=None,
487
- )
488
-
489
- @server.tool()
490
- def app_layout_apply(
491
- profile: str = DEFAULT_PROFILE,
492
- app_key: str = "",
493
- mode: str = "merge",
494
- publish: bool = True,
495
- sections: list[dict] | None = None,
496
- apps: list[dict] | None = None,
497
- ) -> dict:
498
- return ai_builder.app_layout_apply(profile=profile, app_key=app_key, mode=mode, publish=publish, sections=sections or [], apps=apps)
499
-
500
452
  @server.tool()
501
453
  def app_flow_get(
502
454
  profile: str = DEFAULT_PROFILE,
@@ -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
@@ -18,6 +24,7 @@ from .server_app_user import build_user_server
18
24
 
19
25
 
20
26
  BUILDER_TOOL_PREFIX = "builder_"
27
+ MAX_OAUTH_PROXY_BODY_BYTES = 64 * 1024
21
28
 
22
29
 
23
30
  ASGIApp = Callable[
@@ -36,11 +43,15 @@ class CloudMcpAuthApp:
36
43
  app: ASGIApp,
37
44
  broker: CloudAuthBroker,
38
45
  *,
46
+ oauth_proxy: OAuthServiceProxy | None = None,
47
+ oauth_token_resolver: OAuthAccessTokenResolver | None = None,
39
48
  allowed_hosts: list[str] | None = None,
40
49
  internal_host: str | None = None,
41
50
  ) -> None:
42
51
  self._app = app
43
52
  self._broker = broker
53
+ self._oauth_proxy = oauth_proxy
54
+ self._oauth_token_resolver = oauth_token_resolver
44
55
  self._allowed_hosts = [
45
56
  host.strip().lower()
46
57
  for host in (allowed_hosts if allowed_hosts is not None else get_cloud_http_allowed_hosts())
@@ -69,22 +80,52 @@ class CloudMcpAuthApp:
69
80
  await self._json_response(send, 200, {"status": "ok"})
70
81
  return
71
82
 
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
- )
83
+ if self._oauth_proxy is not None:
84
+ backend_metadata_path = self._oauth_proxy.config.backend_metadata_path(path)
85
+ if backend_metadata_path is not None:
86
+ await self._proxy_oauth_metadata(
87
+ scope,
88
+ send,
89
+ backend_metadata_path,
90
+ )
91
+ return
92
+ backend_oauth_path = self._oauth_proxy.config.backend_oauth_endpoint_path(path)
93
+ if backend_oauth_path is not None:
94
+ await self._proxy_oauth_endpoint(
95
+ scope,
96
+ receive,
97
+ send,
98
+ backend_oauth_path,
99
+ )
100
+ return
101
+
102
+ auth_value, explicit_credential, call_platform = self._extract_auth_value(scope)
103
+ if auth_value is None:
104
+ await self._authentication_required(send)
82
105
  return
83
106
 
107
+ use_oauth = (
108
+ not explicit_credential
109
+ and self._oauth_token_resolver is not None
110
+ and not auth_value.startswith("mcp_")
111
+ )
84
112
  try:
85
- cloud_context = await anyio.to_thread.run_sync(self._broker.resolve, credential)
113
+ if use_oauth:
114
+ assert self._oauth_token_resolver is not None
115
+ cloud_context = await anyio.to_thread.run_sync(
116
+ self._resolve_oauth_or_legacy_credential,
117
+ auth_value,
118
+ )
119
+ else:
120
+ cloud_context = await anyio.to_thread.run_sync(
121
+ self._broker.resolve,
122
+ auth_value,
123
+ )
86
124
  except QingflowApiError as error:
87
125
  status = 401 if is_auth_like_error(error) else 500
126
+ if status == 401 and use_oauth:
127
+ await self._oauth_authentication_required(send, "invalid_token", error.message)
128
+ return
88
129
  await self._json_response(
89
130
  send,
90
131
  status,
@@ -97,30 +138,158 @@ class CloudMcpAuthApp:
97
138
  )
98
139
  return
99
140
 
100
- token = current_cloud_context.set(cloud_context)
141
+ if not call_platform:
142
+ call_platform = await anyio.to_thread.run_sync(
143
+ self._broker.resolve_call_platform,
144
+ cloud_context.token,
145
+ )
146
+
147
+ context_token = current_cloud_context.set(cloud_context)
148
+ call_platform_token = current_call_platform.set(call_platform)
101
149
  try:
102
150
  scope = _ensure_streamable_http_accept(scope)
103
151
  await self._app(scope, receive, send)
104
152
  finally:
105
- current_cloud_context.reset(token)
153
+ current_call_platform.reset(call_platform_token)
154
+ current_cloud_context.reset(context_token)
106
155
 
107
- def _extract_credential(self, scope: dict[str, Any]) -> str | None:
156
+ def _resolve_oauth_or_legacy_credential(self, auth_value: str) -> CloudMcpContext:
157
+ assert self._oauth_token_resolver is not None
158
+ try:
159
+ return self._oauth_token_resolver.resolve(auth_value)
160
+ except QingflowApiError as oauth_error:
161
+ if not is_auth_like_error(oauth_error):
162
+ raise
163
+ try:
164
+ return self._broker.resolve(auth_value)
165
+ except QingflowApiError:
166
+ raise oauth_error
167
+
168
+ def _extract_auth_value(self, scope: dict[str, Any]) -> tuple[str | None, bool, str]:
108
169
  headers = {
109
170
  key.decode("latin1").lower(): value.decode("latin1")
110
171
  for key, value in scope.get("headers", [])
111
172
  }
173
+ call_platform = headers.get("x-qf-callplatform", "").strip()
112
174
  authorization = headers.get("authorization", "")
113
175
  if authorization.lower().startswith("bearer "):
114
- credential = authorization[7:].strip()
115
- return credential or None
176
+ bearer_token = authorization[7:].strip()
177
+ return bearer_token or None, False, call_platform
116
178
  credential = headers.get("x-qingflow-client-id", "").strip()
117
- return credential or None
179
+ return credential or None, True, call_platform
180
+
181
+ async def _authentication_required(
182
+ self,
183
+ send: Callable[[dict[str, Any]], Awaitable[None]],
184
+ ) -> None:
185
+ if self._oauth_proxy is not None:
186
+ await self._oauth_authentication_required(
187
+ send,
188
+ "invalid_token",
189
+ "Authentication required",
190
+ )
191
+ return
192
+ await self._json_response(
193
+ send,
194
+ 401,
195
+ {
196
+ "error": "missing_mcp_credential",
197
+ "message": "Authorization: Bearer <mcp_credential> is required",
198
+ },
199
+ )
200
+
201
+ async def _oauth_authentication_required(
202
+ self,
203
+ send: Callable[[dict[str, Any]], Awaitable[None]],
204
+ error: str,
205
+ description: str,
206
+ ) -> None:
207
+ assert self._oauth_proxy is not None
208
+ challenge = (
209
+ 'Bearer realm="OAuth", '
210
+ f'error="{_escape_auth_param(error)}", '
211
+ f'error_description="{_escape_auth_param(description)}", '
212
+ f'resource_metadata="{self._oauth_proxy.config.resource_metadata_url}"'
213
+ )
214
+ await self._json_response(
215
+ send,
216
+ 401,
217
+ {"error": error, "error_description": description},
218
+ extra_headers=[
219
+ (b"www-authenticate", challenge.encode("latin1", errors="replace")),
220
+ (b"cache-control", b"no-store"),
221
+ (b"pragma", b"no-cache"),
222
+ ],
223
+ )
224
+
225
+ async def _proxy_oauth_metadata(
226
+ self,
227
+ scope: dict[str, Any],
228
+ send: Callable[[dict[str, Any]], Awaitable[None]],
229
+ backend_path: str,
230
+ ) -> None:
231
+ assert self._oauth_proxy is not None
232
+ try:
233
+ response = await anyio.to_thread.run_sync(
234
+ self._oauth_proxy.request_metadata,
235
+ str(scope.get("method") or "GET"),
236
+ backend_path,
237
+ bytes(scope.get("query_string") or b""),
238
+ )
239
+ except QingflowApiError as error:
240
+ await self._json_response(
241
+ send,
242
+ 502,
243
+ {"error": "oauth_service_unavailable", "message": error.message},
244
+ )
245
+ return
246
+ await self._raw_response(send, response)
247
+
248
+ async def _proxy_oauth_endpoint(
249
+ self,
250
+ scope: dict[str, Any],
251
+ receive: Callable[[], Awaitable[dict[str, Any]]],
252
+ send: Callable[[dict[str, Any]], Awaitable[None]],
253
+ backend_path: str,
254
+ ) -> None:
255
+ assert self._oauth_proxy is not None
256
+ try:
257
+ body = await _read_request_body(receive, MAX_OAUTH_PROXY_BODY_BYTES)
258
+ except ValueError:
259
+ await self._json_response(
260
+ send,
261
+ 413,
262
+ {
263
+ "error": "invalid_request",
264
+ "error_description": "OAuth request body is too large",
265
+ },
266
+ )
267
+ return
268
+ try:
269
+ response = await anyio.to_thread.run_sync(
270
+ self._oauth_proxy.request_oauth_endpoint,
271
+ str(scope.get("method") or "GET"),
272
+ backend_path,
273
+ bytes(scope.get("query_string") or b""),
274
+ body,
275
+ tuple(scope.get("headers") or ()),
276
+ )
277
+ except QingflowApiError as error:
278
+ await self._json_response(
279
+ send,
280
+ 502,
281
+ {"error": "oauth_service_unavailable", "message": error.message},
282
+ )
283
+ return
284
+ await self._raw_response(send, response)
118
285
 
119
286
  async def _json_response(
120
287
  self,
121
288
  send: Callable[[dict[str, Any]], Awaitable[None]],
122
289
  status: int,
123
290
  payload: dict[str, Any],
291
+ *,
292
+ extra_headers: list[tuple[bytes, bytes]] | None = None,
124
293
  ) -> None:
125
294
  body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
126
295
  await send(
@@ -130,11 +299,26 @@ class CloudMcpAuthApp:
130
299
  "headers": [
131
300
  (b"content-type", b"application/json; charset=utf-8"),
132
301
  (b"content-length", str(len(body)).encode("ascii")),
133
- ],
302
+ ] + list(extra_headers or []),
134
303
  }
135
304
  )
136
305
  await send({"type": "http.response.body", "body": body})
137
306
 
307
+ async def _raw_response(
308
+ self,
309
+ send: Callable[[dict[str, Any]], Awaitable[None]],
310
+ response: OAuthProxyResponse,
311
+ ) -> None:
312
+ await send(
313
+ {
314
+ "type": "http.response.start",
315
+ "status": response.status_code,
316
+ "headers": list(response.headers)
317
+ + [(b"content-length", str(len(response.body)).encode("ascii"))],
318
+ }
319
+ )
320
+ await send({"type": "http.response.body", "body": response.body})
321
+
138
322
  async def _plain_response(
139
323
  self,
140
324
  send: Callable[[dict[str, Any]], Awaitable[None]],
@@ -176,12 +360,25 @@ def build_cloud_user_app() -> CloudMcpAuthApp:
176
360
 
177
361
  def build_cloud_app() -> CloudMcpAuthApp:
178
362
  server = build_combined_server()
179
- broker = CloudAuthBroker.from_config(backend=BackendClient())
180
- return CloudMcpAuthApp(server.streamable_http_app(), broker)
363
+ backend = BackendClient()
364
+ broker = CloudAuthBroker.from_config(backend=backend)
365
+ oauth_config = CloudOAuthProxyConfig.from_config()
366
+ oauth_proxy = OAuthServiceProxy(oauth_config) if oauth_config is not None else None
367
+ oauth_token_resolver = (
368
+ OAuthAccessTokenResolver.from_config(backend)
369
+ if oauth_config is not None
370
+ else None
371
+ )
372
+ return CloudMcpAuthApp(
373
+ server.streamable_http_app(),
374
+ broker,
375
+ oauth_proxy=oauth_proxy,
376
+ oauth_token_resolver=oauth_token_resolver,
377
+ )
181
378
 
182
379
 
183
380
  def build_combined_server() -> FastMCP:
184
- server = build_user_server()
381
+ server = build_user_server(stateless_http=True)
185
382
  builder_server = build_builder_server()
186
383
  server._mcp_server.name = "Qingflow Combined MCP"
187
384
  _register_prefixed_builder_tools(server, builder_server)
@@ -237,6 +434,22 @@ def _extract_host(scope: dict[str, Any]) -> str:
237
434
  return ""
238
435
 
239
436
 
437
+ async def _read_request_body(
438
+ receive: Callable[[], Awaitable[dict[str, Any]]],
439
+ limit: int,
440
+ ) -> bytes:
441
+ body = bytearray()
442
+ while True:
443
+ message = await receive()
444
+ if message.get("type") != "http.request":
445
+ return bytes(body)
446
+ body.extend(bytes(message.get("body") or b""))
447
+ if len(body) > limit:
448
+ raise ValueError("request body is too large")
449
+ if not message.get("more_body", False):
450
+ return bytes(body)
451
+
452
+
240
453
  def _is_allowed_host(host: str, allowed_hosts: list[str]) -> bool:
241
454
  normalized_host = host.strip().lower().rstrip(".")
242
455
  host_without_port = _strip_port(normalized_host)
@@ -308,6 +521,16 @@ def _ensure_streamable_http_accept(scope: dict[str, Any]) -> dict[str, Any]:
308
521
  return rewritten
309
522
 
310
523
 
524
+ def _escape_auth_param(value: str) -> str:
525
+ return (
526
+ str(value or "")
527
+ .replace("\\", "\\\\")
528
+ .replace('"', '\\"')
529
+ .replace("\r", " ")
530
+ .replace("\n", " ")
531
+ )
532
+
533
+
311
534
  app = LazyCloudApp()
312
535
 
313
536