@qingflow-tech/qingflow-cli 1.1.60 → 1.1.61-beta.2

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 (62) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +3 -1
  4. package/skills/qingflow-cli/SKILL.md +120 -8
  5. package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +1 -1
  6. package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +1 -1
  7. package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +2 -2
  8. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +9 -5
  9. package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +3 -3
  10. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +4 -4
  11. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +1 -1
  12. package/skills/qingflow-cli/reference/data/README.md +20 -0
  13. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md +7 -1
  14. package/skills/qingflow-common/SKILL.md +5 -2
  15. package/src/qingflow_mcp/app_form_compat.py +3 -0
  16. package/src/qingflow_mcp/app_form_validator.py +69 -17
  17. package/src/qingflow_mcp/backend_client.py +33 -8
  18. package/src/qingflow_mcp/builder_facade/app_form.py +497 -100
  19. package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +1 -0
  20. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +64 -30
  21. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +4 -8
  22. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +4 -8
  23. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +6 -10
  24. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +6 -10
  25. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +11 -18
  26. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +4 -8
  27. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +4 -8
  28. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +6 -10
  29. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +4 -8
  30. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +4 -8
  31. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +4 -8
  32. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +6 -10
  33. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +4 -8
  34. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +4 -8
  35. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +4 -8
  36. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +6 -10
  37. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +46 -15
  38. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +146 -0
  39. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +4 -8
  40. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +4 -8
  41. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +61 -27
  42. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +4 -8
  43. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +43 -0
  44. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +7 -12
  45. package/src/qingflow_mcp/builder_facade/service.py +39 -1
  46. package/src/qingflow_mcp/cli/commands/data.py +13 -3
  47. package/src/qingflow_mcp/cli/formatters.py +7 -0
  48. package/src/qingflow_mcp/cli/main.py +34 -1
  49. package/src/qingflow_mcp/cloud_context.py +16 -1
  50. package/src/qingflow_mcp/public_surface.py +67 -8
  51. package/src/qingflow_mcp/response_trim.py +2 -1
  52. package/src/qingflow_mcp/server.py +13 -7
  53. package/src/qingflow_mcp/server_app_builder.py +9 -5
  54. package/src/qingflow_mcp/server_app_user.py +10 -6
  55. package/src/qingflow_mcp/server_cloud.py +43 -7
  56. package/src/qingflow_mcp/tool_catalog.py +387 -0
  57. package/src/qingflow_mcp/tool_catalog_i18n.py +185 -0
  58. package/src/qingflow_mcp/tool_policy.py +197 -0
  59. package/src/qingflow_mcp/tools/data_files.py +174 -0
  60. package/src/qingflow_mcp/tools/data_tools.py +42 -13
  61. package/src/qingflow_mcp/tools/record_tools.py +14 -72
  62. package/src/qingflow_mcp/tools/storage_download.py +89 -0
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import argparse
3
4
  from argparse import Namespace
4
5
  from dataclasses import dataclass
5
6
 
@@ -109,6 +110,7 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
109
110
  PublicToolSpec(USER_DOMAIN, "record_export_direct", ("record_export_direct",), ("export", "direct"), cli_show_effective_context=True),
110
111
  PublicToolSpec(USER_DOMAIN, "record_code_block_run", ("record_code_block_run",), ("record", "code-block-run"), cli_show_effective_context=True, cli_context_write=True),
111
112
  PublicToolSpec(USER_DOMAIN, "task_list", ("task_list",), ("task", "list"), cli_show_effective_context=True),
113
+ PublicToolSpec(USER_DOMAIN, "task_workbench", cli_route=("task",), mcp_public=False),
112
114
  PublicToolSpec(USER_DOMAIN, "task_get", ("task_get",), ("task", "get"), cli_show_effective_context=True),
113
115
  PublicToolSpec(USER_DOMAIN, "task_action_execute", ("task_action_execute",), ("task", "action"), cli_show_effective_context=True, cli_context_write=True),
114
116
  PublicToolSpec(
@@ -130,12 +132,12 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
130
132
 
131
133
 
132
134
  BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
133
- PublicToolSpec(BUILDER_DOMAIN, "auth_use_credential", ("auth_use_credential",), ("builder", "auth", "use-credential"), cli_public=False),
134
- PublicToolSpec(BUILDER_DOMAIN, "auth_whoami", ("auth_whoami",), ("builder", "auth", "whoami"), cli_public=False),
135
- PublicToolSpec(BUILDER_DOMAIN, "auth_logout", ("auth_logout",), ("builder", "auth", "logout"), cli_public=False),
135
+ PublicToolSpec(BUILDER_DOMAIN, "auth_use_credential", ("auth_use_credential",), cli_public=False),
136
+ PublicToolSpec(BUILDER_DOMAIN, "auth_whoami", ("auth_whoami",), cli_public=False),
137
+ PublicToolSpec(BUILDER_DOMAIN, "auth_logout", ("auth_logout",), cli_public=False),
136
138
  PublicToolSpec(BUILDER_DOMAIN, "get_login_link", ("get_login_link",), mcp_public=True, cli_public=False),
137
- PublicToolSpec(BUILDER_DOMAIN, "workspace_list", ("workspace_list",), ("builder", "workspace", "list"), cli_public=False),
138
- PublicToolSpec(BUILDER_DOMAIN, "workspace_get", ("workspace_get",), ("builder", "workspace", "get"), cli_public=False),
139
+ PublicToolSpec(BUILDER_DOMAIN, "workspace_list", ("workspace_list",), cli_public=False),
140
+ PublicToolSpec(BUILDER_DOMAIN, "workspace_get", ("workspace_get",), cli_public=False),
139
141
  PublicToolSpec(BUILDER_DOMAIN, "file_upload_local", ("file_upload_local",), ("builder", "file", "upload-local"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
140
142
  PublicToolSpec(BUILDER_DOMAIN, "feedback_submit", ("feedback_submit",), ("builder", "feedback", "submit"), has_contract=True),
141
143
  PublicToolSpec(BUILDER_DOMAIN, "builder_tool_contract", ("builder_tool_contract",), ("builder", "contract"), has_contract=False),
@@ -194,7 +196,8 @@ PUBLIC_TOOL_BY_KEY: dict[str, PublicToolSpec] = {spec.trim_key: spec for spec in
194
196
  PUBLIC_TOOL_BY_CLI_ROUTE: dict[tuple[str, ...], PublicToolSpec] = {
195
197
  spec.cli_route: spec
196
198
  for spec in ALL_PUBLIC_TOOL_SPECS
197
- if spec.cli_public and spec.cli_route is not None
199
+ # 隐藏命令仍有可达 handler,闸门绑定不能把“帮助不可见”误当成“不可执行”。
200
+ if spec.cli_route is not None
198
201
  }
199
202
  PUBLIC_TOOL_BY_CLI_ROUTE.update(
200
203
  {
@@ -205,6 +208,9 @@ PUBLIC_TOOL_BY_CLI_ROUTE.update(
205
208
  }
206
209
  )
207
210
 
211
+ # 这些 handler 只负责版本输出或参数迁移提示,不会发起轻流业务请求。
212
+ NON_TOOL_CLI_HANDLER_ROUTES = frozenset({("version",), ("record", "schema")})
213
+
208
214
 
209
215
  def public_tool_specs(domain: str | None = None) -> tuple[PublicToolSpec, ...]:
210
216
  if domain is None:
@@ -258,11 +264,13 @@ def cli_route_from_namespace(args: Namespace) -> tuple[str, ...] | None:
258
264
  record_command = getattr(args, "record_command", None)
259
265
  if record_command == "schema":
260
266
  schema_command = getattr(args, "record_schema_command", None)
261
- return (command, "schema", schema_command) if isinstance(schema_command, str) and schema_command else None
267
+ if isinstance(schema_command, str) and schema_command:
268
+ return (command, "schema", schema_command)
269
+ return (command, "schema")
262
270
  return (command, record_command) if isinstance(record_command, str) and record_command else None
263
271
  if command == "task":
264
272
  task_command = getattr(args, "task_command", None)
265
- return (command, task_command) if isinstance(task_command, str) and task_command else None
273
+ return (command, task_command) if isinstance(task_command, str) and task_command else (command,)
266
274
  if command != "builder":
267
275
  child_attr = f"{command}_command"
268
276
  child = getattr(args, child_attr, None)
@@ -283,3 +291,54 @@ def cli_route_from_namespace(args: Namespace) -> tuple[str, ...] | None:
283
291
  child_attr = f"builder_{builder_command.replace('-', '_')}_command"
284
292
  child = getattr(args, child_attr, None)
285
293
  return ("builder", builder_command, child) if isinstance(child, str) and child else None
294
+
295
+
296
+ def discover_cli_handler_routes(
297
+ parser: argparse.ArgumentParser,
298
+ ) -> dict[tuple[str, ...], tuple[str, ...]]:
299
+ """从 argparse 实际 handler 树枚举规范化路由及其实现,包含中间交互入口。"""
300
+ discovered: dict[tuple[str, ...], set[str]] = {}
301
+
302
+ def walk(current: argparse.ArgumentParser, path: tuple[str, ...], inherited: dict[str, object]) -> None:
303
+ attrs = dict(inherited)
304
+ for action in current._actions:
305
+ if isinstance(action, argparse._SubParsersAction):
306
+ continue
307
+ if action.dest != argparse.SUPPRESS and action.default != argparse.SUPPRESS:
308
+ attrs.setdefault(action.dest, action.default)
309
+ attrs.update(current._defaults)
310
+
311
+ handler = current._defaults.get("handler")
312
+ if callable(handler):
313
+ variants = [attrs]
314
+ for action in current._actions:
315
+ if isinstance(action, argparse._SubParsersAction) or action.choices is None:
316
+ continue
317
+ variants.extend({**attrs, action.dest: choice} for choice in action.choices)
318
+ for variant in variants:
319
+ route = cli_route_from_namespace(Namespace(**variant))
320
+ if route is None:
321
+ route = _normalize_cli_parser_path(path)
322
+ if route:
323
+ identity = f"{handler.__module__}:{handler.__qualname__}"
324
+ discovered.setdefault(route, set()).add(identity)
325
+
326
+ for action in current._actions:
327
+ if not isinstance(action, argparse._SubParsersAction):
328
+ continue
329
+ for name, child in action.choices.items():
330
+ next_attrs = dict(attrs)
331
+ next_attrs[action.dest] = name
332
+ walk(child, path + (name,), next_attrs)
333
+
334
+ walk(parser, (), {})
335
+ return {route: tuple(sorted(handlers)) for route, handlers in discovered.items()}
336
+
337
+
338
+ def _normalize_cli_parser_path(path: tuple[str, ...]) -> tuple[str, ...]:
339
+ normalized = list(path)
340
+ if normalized and normalized[0] == "build":
341
+ normalized[0] = "builder"
342
+ if len(normalized) > 1 and normalized[0] == "builder" and normalized[1] == "associated-resources":
343
+ normalized[1] = "associated-resource"
344
+ return tuple(normalized)
@@ -51,8 +51,9 @@ COMMON_ERROR_DROP_TOP = {
51
51
 
52
52
  SUCCESS_POLICY_BY_TOOL: dict[str, TransformFn] = {}
53
53
 
54
- #: 这两个校验命令的产出契约明确包含 ok 字段(供阶段 2 SKILL.md 引用),
54
+ #: 这些校验命令的产出契约明确包含 ok 字段(供阶段 2 SKILL.md 引用),
55
55
  #: 需在 success 裁剪时保留 ok(默认 COMMON_SUCCESS_DROP_TOP 会丢弃 ok)。
56
+ #: `builder:app_views_validate`(views 声明式校验)同属校验类工具,ok 同样进产出契约。
56
57
  _KEEP_OK_TOOLS = {
57
58
  "builder:app_form_validate_formula",
58
59
  "builder:app_form_validate_code_block",
@@ -2,8 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  from datetime import date
4
4
 
5
- from mcp.server.fastmcp import FastMCP
6
-
7
5
  from .backend_client import BackendClient
8
6
  from .session_store import SessionStore
9
7
  from .tools.app_tools import AppTools
@@ -26,9 +24,11 @@ from .tools.task_context_tools import TaskContextTools
26
24
  from .tools.view_tools import ViewTools
27
25
  from .tools.workflow_tools import WorkflowTools
28
26
  from .tools.workspace_tools import WorkspaceTools
27
+ from .tool_catalog import stdio_server_bindings
28
+ from .tool_policy import PolicyAwareFastMCP, configure_tool_policy
29
29
 
30
30
 
31
- def _register_missing_builder_tools(server: FastMCP) -> None:
31
+ def _register_missing_builder_tools(server: PolicyAwareFastMCP) -> None:
32
32
  """Add current high-level builder tools to the unified MCP without duplicate names."""
33
33
  from .server_app_builder import build_builder_server
34
34
 
@@ -48,10 +48,10 @@ def _register_missing_builder_tools(server: FastMCP) -> None:
48
48
  )
49
49
 
50
50
 
51
- def build_server() -> FastMCP:
51
+ def build_server() -> PolicyAwareFastMCP:
52
52
  today = date.today()
53
53
  current_year = today.year
54
- server = FastMCP(
54
+ server = PolicyAwareFastMCP(
55
55
  "Qingflow MCP",
56
56
  instructions=f"""Use this unified server for Qingflow operational and builder workflows. Current date: `{today.isoformat()}`.
57
57
 
@@ -149,7 +149,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
149
149
  - `record_delete` deletes by `record_id` or `record_ids`, and requires an accessible system `view_id`; use custom views only to locate records, not as the delete route.
150
150
  - `record_get` is the single-record frontend detail context tool. It returns detail-page visible fields, one-level relation targets, first-page data/workflow logs, associated views/reports, local readable image assets, local downloadable file assets, unavailable context, and `semantic_context`.
151
151
  - Use `record_logs_get` only when the user needs the full visible data/workflow log history for a specific record. It writes JSONL files locally and returns file paths plus completeness metadata; do not expect full log arrays in the response.
152
- - Read record images from `record_get.media_assets.items[].local_path` when `readable_by_agent=true`; read attachments/documents/tables from `record_get.file_assets.items[].local_path` and `extraction.text_path` when present. `record_get` follows the frontend storage cookie redirect path for Qingflow attachments, and remote file URLs should not be treated as directly readable.
152
+ - Download record attachments with `data_get(download_attachments=true, include_schema=true)` for the same app and record; use `file_assets.items[].local_path` only when `access_status=downloaded`. This includes original images and videos. Inspect `file_assets.warnings` for partial failures; do not request remote private URLs or fall back to record/detail tools after a data-plane denial. For specialized detail-page context, read `record_get` rich-text images from `media_assets` and its files from `file_assets` local paths or `extraction.text_path` when present.
153
153
  - `record_get.columns` are focus hints only; they do not project the detail fields. Read facts from top-level `fields[]`.
154
154
  - When readback shape matters after insert or update, prefer `record_get` for human/detail-page context, or `record_list(..., output_profile="normalized")` for batch-shaped normalized rows.
155
155
 
@@ -259,8 +259,14 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
259
259
  WorkflowTools(sessions, backend).register(server)
260
260
  ViewTools(sessions, backend).register(server)
261
261
  SolutionTools(sessions, backend).register(server)
262
+ # 保存补齐 Builder 前的真实注册来源,裸名冲突只能绑定最终保留 callable 的来源域。
263
+ legacy_tool_names = set(server._tool_manager._tools)
262
264
  _register_missing_builder_tools(server)
263
- return server
265
+ return configure_tool_policy(
266
+ server,
267
+ stdio_server_bindings(legacy_tool_names, server._tool_manager._tools),
268
+ source="environment",
269
+ )
264
270
 
265
271
 
266
272
  mcp = build_server()
@@ -2,8 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
 
5
- from mcp.server.fastmcp import FastMCP
6
-
7
5
  from .backend_client import BackendClient
8
6
  from .config import DEFAULT_PROFILE
9
7
  from .response_trim import BUILDER_SERVER_METHOD_MAP, trim_error_response, trim_public_response, wrap_trimmed_methods
@@ -14,6 +12,8 @@ from .tools.auth_tools import AuthTools
14
12
  from .tools.feedback_tools import FeedbackTools
15
13
  from .tools.file_tools import FileTools
16
14
  from .tools.workspace_tools import WorkspaceTools
15
+ from .tool_catalog import builder_server_bindings
16
+ from .tool_policy import PolicyAwareFastMCP, configure_tool_policy
17
17
 
18
18
 
19
19
  def _config_failure(message: str, *, fix_hint: str) -> dict:
@@ -27,8 +27,8 @@ def _config_failure(message: str, *, fix_hint: str) -> dict:
27
27
  }
28
28
 
29
29
 
30
- def build_builder_server() -> FastMCP:
31
- server = FastMCP(
30
+ def build_builder_server() -> PolicyAwareFastMCP:
31
+ server = PolicyAwareFastMCP(
32
32
  "Qingflow App Builder MCP",
33
33
  instructions=(
34
34
  "Use this server for AI-native Qingflow builder workflows. "
@@ -689,7 +689,11 @@ def build_builder_server() -> FastMCP:
689
689
  expected_package_id=expected_package_id,
690
690
  )
691
691
 
692
- return server
692
+ return configure_tool_policy(
693
+ server,
694
+ builder_server_bindings(server._tool_manager._tools),
695
+ source="environment",
696
+ )
693
697
 
694
698
 
695
699
  def main() -> None:
@@ -3,8 +3,6 @@ from __future__ import annotations
3
3
  import json
4
4
  from datetime import date
5
5
 
6
- from mcp.server.fastmcp import FastMCP
7
-
8
6
  from .backend_client import BackendClient
9
7
  from .config import DEFAULT_PROFILE
10
8
  from .response_trim import USER_SERVER_METHOD_MAP, trim_error_response, trim_public_response, wrap_trimmed_methods
@@ -22,12 +20,14 @@ from .tools.login_link_tools import LOGIN_LINK_DESCRIPTION, LoginLinkTools
22
20
  from .tools.resource_read_tools import ResourceReadTools
23
21
  from .tools.task_context_tools import TaskContextTools
24
22
  from .tools.workspace_tools import WorkspaceTools
23
+ from .tool_catalog import user_server_bindings
24
+ from .tool_policy import PolicyAwareFastMCP, configure_tool_policy
25
25
 
26
26
 
27
- def build_user_server(*, stateless_http: bool = False) -> FastMCP:
27
+ def build_user_server(*, stateless_http: bool = False) -> PolicyAwareFastMCP:
28
28
  today = date.today()
29
29
  current_year = today.year
30
- server = FastMCP(
30
+ server = PolicyAwareFastMCP(
31
31
  "Qingflow App User MCP",
32
32
  stateless_http=stateless_http,
33
33
  instructions=f"""Use this server for Qingflow operational workflows. Current date: `{today.isoformat()}`.
@@ -144,7 +144,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
144
144
  - `record_delete` deletes by `record_id` or `record_ids`, and requires an accessible system `view_id`; use custom views only to locate records, not as the delete route.
145
145
  - `record_get` is the single-record frontend detail context tool. It returns detail-page visible fields, one-level relation targets, first-page data/workflow logs, associated views/reports, local readable image assets, local downloadable file assets, unavailable context, and `semantic_context`.
146
146
  - Use `record_logs_get` only when the user needs the full visible data/workflow log history for a specific record. It writes JSONL files locally and returns file paths plus completeness metadata; do not expect full log arrays in the response.
147
- - Read record images from `record_get.media_assets.items[].local_path` when `readable_by_agent=true`; read attachments/documents/tables from `record_get.file_assets.items[].local_path` and `extraction.text_path` when present. `record_get` follows the frontend storage cookie redirect path for Qingflow attachments, and remote file URLs should not be treated as directly readable.
147
+ - Download record attachments with `data_get(download_attachments=true, include_schema=true)` for the same app and record; use `file_assets.items[].local_path` only when `access_status=downloaded`. This includes original images and videos. Inspect `file_assets.warnings` for partial failures; do not request remote private URLs or fall back to record/detail tools after a data-plane denial. For specialized detail-page context, read `record_get` rich-text images from `media_assets` and its files from `file_assets` local paths or `extraction.text_path` when present.
148
148
  - `record_get.columns` are focus hints only; they do not project the detail fields. Read facts from top-level `fields[]`.
149
149
  - When readback shape matters after insert or update, prefer `record_get` for human/detail-page context, or `record_list(..., output_profile="normalized")` for batch-shaped normalized rows.
150
150
 
@@ -507,7 +507,11 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
507
507
  task_context_tools.register(server)
508
508
  directory_tools.register_frontend_search(server)
509
509
 
510
- return server
510
+ return configure_tool_policy(
511
+ server,
512
+ user_server_bindings(server._tool_manager._tools),
513
+ source="request" if stateless_http else "environment",
514
+ )
511
515
 
512
516
 
513
517
  mcp = build_user_server()
@@ -6,11 +6,15 @@ from typing import Any
6
6
 
7
7
  import anyio
8
8
  import uvicorn
9
- from mcp.server.fastmcp import FastMCP
10
-
11
9
  from .backend_client import BackendClient
12
10
  from .cloud_auth import CloudAuthBroker
13
- from .cloud_context import CloudMcpContext, current_call_platform, current_cloud_context
11
+ from .cloud_context import (
12
+ CloudMcpContext,
13
+ current_call_platform,
14
+ current_cloud_context,
15
+ current_http_tool_policy,
16
+ current_tool_policy_header,
17
+ )
14
18
  from .cloud_oauth_proxy import (
15
19
  CloudOAuthProxyConfig,
16
20
  OAuthAccessTokenResolver,
@@ -21,6 +25,8 @@ from .config import get_cloud_http_allowed_hosts, get_cloud_http_host, get_cloud
21
25
  from .errors import QingflowApiError, is_auth_like_error
22
26
  from .server_app_builder import build_builder_server
23
27
  from .server_app_user import build_user_server
28
+ from .tool_catalog import combined_server_bindings
29
+ from .tool_policy import PolicyAwareFastMCP, configure_tool_policy, parse_http_tool_policy
24
30
 
25
31
 
26
32
  BUILDER_TOOL_PREFIX = "builder_"
@@ -164,12 +170,18 @@ class CloudMcpAuthApp:
164
170
  cloud_context.token,
165
171
  )
166
172
 
173
+ tool_policy_header = _extract_header(scope, "x-qingflow-tool-policy")
174
+ http_tool_policy = parse_http_tool_policy(tool_policy_header, bytes(scope.get("query_string") or b""))
167
175
  context_token = current_cloud_context.set(cloud_context)
168
176
  call_platform_token = current_call_platform.set(call_platform)
177
+ tool_policy_token = current_tool_policy_header.set(tool_policy_header)
178
+ http_tool_policy_token = current_http_tool_policy.set(http_tool_policy)
169
179
  try:
170
180
  scope = _ensure_streamable_http_accept(scope)
171
181
  await self._app(scope, receive, send)
172
182
  finally:
183
+ current_http_tool_policy.reset(http_tool_policy_token)
184
+ current_tool_policy_header.reset(tool_policy_token)
173
185
  current_call_platform.reset(call_platform_token)
174
186
  current_cloud_context.reset(context_token)
175
187
 
@@ -397,16 +409,29 @@ def build_cloud_app() -> CloudMcpAuthApp:
397
409
  )
398
410
 
399
411
 
400
- def build_combined_server() -> FastMCP:
412
+ def build_combined_server() -> PolicyAwareFastMCP:
401
413
  server = build_user_server(stateless_http=True)
402
414
  builder_server = build_builder_server()
415
+ user_tool_names = set(server._tool_manager._tools)
416
+ builder_tool_names = set(builder_server._tool_manager._tools)
403
417
  server._mcp_server.name = "Qingflow Combined MCP"
404
418
  _register_prefixed_builder_tools(server, builder_server)
405
419
  _set_combined_instructions(server, builder_server)
406
- return server
420
+ return configure_tool_policy(
421
+ server,
422
+ combined_server_bindings(
423
+ user_tool_names,
424
+ builder_tool_names,
425
+ builder_prefix=BUILDER_TOOL_PREFIX,
426
+ ),
427
+ source="request",
428
+ )
407
429
 
408
430
 
409
- def _register_prefixed_builder_tools(target_server: FastMCP, builder_server: FastMCP) -> None:
431
+ def _register_prefixed_builder_tools(
432
+ target_server: PolicyAwareFastMCP,
433
+ builder_server: PolicyAwareFastMCP,
434
+ ) -> None:
410
435
  for original_name, tool in builder_server._tool_manager._tools.items():
411
436
  target_server._tool_manager.add_tool(
412
437
  tool.fn,
@@ -419,7 +444,10 @@ def _register_prefixed_builder_tools(target_server: FastMCP, builder_server: Fas
419
444
  )
420
445
 
421
446
 
422
- def _set_combined_instructions(user_server: FastMCP, builder_server: FastMCP) -> None:
447
+ def _set_combined_instructions(
448
+ user_server: PolicyAwareFastMCP,
449
+ builder_server: PolicyAwareFastMCP,
450
+ ) -> None:
423
451
  user_instructions = user_server.instructions or ""
424
452
  builder_instructions = builder_server.instructions or ""
425
453
  user_server._mcp_server.instructions = f"""Use this combined Qingflow MCP server for both user operational workflows and builder workflows.
@@ -454,6 +482,14 @@ def _extract_host(scope: dict[str, Any]) -> str:
454
482
  return ""
455
483
 
456
484
 
485
+ def _extract_header(scope: dict[str, Any], name: str) -> str | None:
486
+ expected = name.lower().encode("latin1")
487
+ for key, value in scope.get("headers", []):
488
+ if key.lower() == expected:
489
+ return value.decode("utf-8", errors="replace")
490
+ return None
491
+
492
+
457
493
  async def _read_request_body(
458
494
  receive: Callable[[], Awaitable[dict[str, Any]]],
459
495
  limit: int,