@qingflow-tech/qingflow-cli 1.1.61-beta.1 → 1.1.61-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@ Qingflow CLI 是轻流面向开发者和 AI Agent 的命令行工具。它把轻
7
7
  ## 安装
8
8
 
9
9
  ```bash
10
- npm install @qingflow-tech/qingflow-cli@1.1.61-beta.1
10
+ npm install @qingflow-tech/qingflow-cli@1.1.61-beta.3
11
11
  ```
12
12
 
13
13
  推荐从官网复制这一条一键安装 Qingflow CLI:
@@ -21,7 +21,7 @@ npx qingflow-cli@latest install
21
21
  ## 运行
22
22
 
23
23
  ```bash
24
- npx -y -p @qingflow-tech/qingflow-cli@1.1.61-beta.1 qingflow
24
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.61-beta.3 qingflow
25
25
  ```
26
26
 
27
27
  全局安装后可以直接运行:
@@ -62,7 +62,7 @@ qingflow-skills install --agent codex --scope user
62
62
  如果你通过一次性 `npx -p` 执行 skills 安装,建议加 `--copy`,避免 symlink 指向 npm 临时执行缓存:
63
63
 
64
64
  ```bash
65
- npx -y -p @qingflow-tech/qingflow-cli@1.1.61-beta.1 qingflow-skills install --agent codex --scope user --copy
65
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.61-beta.3 qingflow-skills install --agent codex --scope user --copy
66
66
  ```
67
67
 
68
68
  `qingflow-skills install` 默认使用 symlink,且遇到同名 skill 不会覆盖;只有显式传入 `--force` 才会替换。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qingflow-tech/qingflow-cli",
3
- "version": "1.1.61-beta.1",
3
+ "version": "1.1.61-beta.3",
4
4
  "description": "Human-friendly Qingflow command line interface for auth, record operations, import, tasks, and stable builder flows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "1.1.61b1"
7
+ version = "1.1.61b3"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -23,7 +23,7 @@ description: |
23
23
  | [reference/00-INDEX.md](./reference/00-INDEX.md) | **总导航**:按用户目标选择 record / task / builder 文档;复杂任务先读 |
24
24
  | [reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md](./reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md) | **普通成员**主线:最短路径、`view_id`、与管理命令的差异预期 |
25
25
  | [reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md](./reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md) | **字段数据类型**:`record schema …` 的 `fields[]`(`field_id`、`title`、`kind`、`options`…);`record list/get/access` 与写入 payload 的形态对照 |
26
- | [reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md](./reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md) | **应用记录获取数据**:`app get`(`accessible_views`)→ `record schema browse` → `record list(query)` 模糊定位 / `record get` 详情页首屏上下文 / `record logs` 全量日志;分析取数转 `record_access` |
26
+ | [reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md](./reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md) | **普通应用数据读取**:`data query` 获取记录和 schema → 按需 `data get` 读取单条;附件下载见 [data 操作说明](./reference/data/README.md) |
27
27
  | [reference/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md](./reference/task/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md) | **任务(待办已办)上下文 SOP**:`task list` → `task get` → `task log` → `task report` → `task action`;只把 `task_id` 作为主定位参数 |
28
28
  | [reference/QINGFLOW_CLI_RECORD_IMPORT_WORKFLOW.md](./reference/record/QINGFLOW_CLI_RECORD_IMPORT_WORKFLOW.md) | **批量导入 SOP**:`app_get`(**`import_capability`**)→ `record_import_template_get` → `record_import_verify` →(可选)`record_import_repair_local` → `record_import_start` → `record_import_status_get`;CLI `import` / `record schema import` |
29
29
  | [reference/builder/README.md](./reference/builder/README.md) | **Builder 总导航**:完整系统、单应用、字段、布局、视图、报表、门户、按钮/关联资源、流程、发布校验 |
@@ -40,7 +40,7 @@ description: |
40
40
  | [reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md](./reference/record/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md) | **新建记录**:`record schema insert -> record insert --items-file`;成员/部门/关联优先自然语言输入;细节见 [record/insert](./reference/record/insert/README.md) |
41
41
  | [reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md](./reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md) | **更新记录**:主链路是 `record get` → 智能体按 `fields[]` 组 key/value → **`record update`**;`record schema update` 仅用于失败后诊断可写字段/路径 |
42
42
  | [reference/QINGFLOW_CLI_RECORD_DELETE_WORKFLOW.md](./reference/record/QINGFLOW_CLI_RECORD_DELETE_WORKFLOW.md) | **删除记录**:`record list/get` 精确定位 → `record delete --app-key --record-id --view-id system:*`;批量用 `--record-ids-file` |
43
- | [reference/record/analysis/README.md](./reference/record/analysis/README.md) | **数据分析**:最终统计结论、分析报告、趋势/排名/比例/分布统一走 `record access -> Python/pandas` |
43
+ | [reference/record/analysis/README.md](./reference/record/analysis/README.md) | **数据分析**:最终统计结论、分析报告、趋势/排名/比例/分布统一走 `data query --all --output csv -> Python/pandas` |
44
44
  | [reference/builder/reference/app-delivery-sop.md](./reference/builder/reference/app-delivery-sop.md) | **底层交付参考**:历史完整 SOP、权限、系统列和工具细节;不是主入口 |
45
45
  | [reference/builder/reference/workspace-icons.md](./reference/builder/reference/workspace-icons.md) | **图标参考**:`builder icon catalog`;新建应用包、应用、门户必须显式选择非 `template` 的 `icon + color` |
46
46
  | [reference/builder/reference/match-rules.md](./reference/builder/reference/match-rules.md) | **统一筛选 / 条件写法**:`filters`、`visible_when`、QingBI 图表筛选、工作流条件、`match_mappings`、`field_mappings` 的 `operator/value` 规则 |
@@ -129,18 +129,11 @@ qingflow --json data query --app-key APP_KEY > tmp/qingflow_data_query.json
129
129
 
130
130
  ---
131
131
 
132
- ## 应用记录:获取数据工作流(推荐闭环)
132
+ ## 应用数据:查询与读取(默认路径)
133
133
 
134
- 与常见 **编排/API 文档**里的命名对齐时的固定顺序为:
134
+ 已知 `app_key` 时直接执行 `qingflow --json data query --app-key <APP_KEY>`;首次查询保留默认 schema,根据响应中的 `field_capabilities` 和 `hints` 决定筛选、分页或停止。定位到 `record_id` 后,按需执行 `qingflow --json data get --app-key <APP_KEY> --record-id <RECORD_ID>`;下载附件时加 `--download-attachments`。
135
135
 
136
- 1. **`app_get`**:`qingflow --json app get --app-key <APP_KEY>` → 在 **`data.accessible_views`**(或等价路径)选取 **`view_id`**。
137
- 2. **`record_schema_get`(`schema_mode=browse`)**:`qingflow --json record schema browse --app-key <APP_KEY> --view-id <VIEW_ID>`(CLI 子命令 **`browse`** 即浏览视图表结构)。
138
- 3. **`record_list` / `record_get` / `record_logs_get`**:`record list …` 用于样本浏览或模糊定位候选;`record get --record-id …` 下钻单条前端详情页首屏上下文(字段、首屏日志、引用、图片与文件资产);只有需要完整审计历史时,再用 `record logs --record-id …` 读取全量数据日志和流程日志 JSONL。
139
-
140
- `app get` 里的 `CUSTOM_VIEW_LIST_UNAVAILABLE` 表示自定义视图列表读取被权限或后端限制降级;若 `accessible_views` 中仍有 `system:*`,可继续用该视图读数。若没有任何视图,再回到前端 URL、`app list` 可见应用、或用户提供的 `viewgraphKey` 补充上下文,不要直接断言应用不可读。
141
- `app get.data.can_create=false` 才表示 applicant/create 路径不可用;创建权限探测遇到 auth / server 等非权限类错误时应直接按失败处理,不要包装成“未知创建权限”或“用户无新建权限”。
142
-
143
- **细则、响应解包、`view_id` 雷区与命令模板**见 **[reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md](./reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md)**(编写前已对当前 CLI **实跑**校验)。若仅需「最快看一眼有没有行」,可省略 **`schema browse`**,直接走下文「最短路径」。如果用户要任何最终统计结论、分析报告、趋势/排名/比例/分布,按 **[record/analysis](./reference/record/analysis/README.md)** 执行 `record access -> Python/pandas`。
136
+ 参数与附件处理见 [data 操作说明](./reference/data/README.md)。详情页引用、关联资源和完整日志见 [record 专项参考](./reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md);最终统计结论按 [数据分析说明](./reference/record/analysis/README.md) 使用 `data query --all --output csv`。
144
137
 
145
138
  ---
146
139
 
@@ -167,7 +160,7 @@ qingflow --json data query --app-key APP_KEY > tmp/qingflow_data_query.json
167
160
 
168
161
  **更新路径**:`record update` 会自动先尝试数据管理员直改,再 fallback 到前端同源的 custom view 详情编辑路径;如果当前用户存在这条记录的唯一待办且目标字段在当前节点可编辑,最后会尝试 workflow save-only。成功时只需要读 **`status / update_route / verification_status`**;失败时再读失败原因与路线诊断。不要把中间 route 或辅助 schema/readback 探测的 40002 / 40027 / 404 当作最终失败。
169
162
 
170
- **大号 `record_id`(常见于 Snowflake 类 id)**:若干 CLI 版本在 **`record update` 写入路径**上对 `apply_id` 会做 **JavaScript 安全整数**相关校验,`list`/`get`/`schema update` 仍可能正常而 **更新落库报错**(常为 `apply_id must be positive`)。排障见 **[reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md](./reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md)**。业务统计与最终结论仍必须按 **[record/analysis](./reference/record/analysis/README.md)** 使用 `record access -> Python/pandas`。
163
+ **大号 `record_id`(常见于 Snowflake 类 id)**:若干 CLI 版本在 **`record update` 写入路径**上对 `apply_id` 会做 **JavaScript 安全整数**相关校验,`list`/`get`/`schema update` 仍可能正常而 **更新落库报错**(常为 `apply_id must be positive`)。排障见 **[reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md](./reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md)**。业务统计与最终结论仍必须按 **[record/analysis](./reference/record/analysis/README.md)** 使用 `data query --all --output csv -> Python/pandas`。
171
164
 
172
165
  **命令模板、批量 `items` 形态、与新建相同的写入值约定**(按 **`kind`** 分题型见 **[QINGFLOW_CLI_FIELD_DATA_TYPES.md](./reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md)**)见 **[reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md](./reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md)**。
173
166
 
@@ -242,16 +235,16 @@ qingflow --json data query \
242
235
 
243
236
  - `data insert/update/delete` 是普通应用数据直接写入入口。写入前用只读 `data query/get` 获取 schema、字段能力与访问范围;删除、覆盖已有值、批量写入或其他不可逆操作必须先确认目标和影响范围。
244
237
  - `task`、builder、import 均按路由表进入专项文档;主 Skill 不复制其参数、枚举或完整 SOP。
245
- - 导出/下载只有用户明确需要文件时才使用,并按对应命令要求提供视图上下文;不要把导出当作普通查询或统计分析默认入口。
238
+ - 导出/下载只有用户明确需要文件时才使用,并按对应命令的参数执行;不要把导出当作普通查询或统计分析默认入口。
246
239
  - 查看视图时,`baseInfo` 或 `viewConfig` 只有 40002 / 40027 / 404 这类权限/缺失边界可用另一侧降级,非权限类后端错误不隐藏;完整规则见 Builder 视图文档。
247
240
 
248
- ## record 专项
241
+ ## 专项上下文
249
242
 
250
- `record` 用于详情页专项上下文、完整数据/流程日志、代码块字段执行,以及用户指定的 `record` 操作。普通记录附件下载使用 `data get --download-attachments`。
243
+ 详情页引用、关联资源、完整数据/流程日志或代码块字段执行见 [record 专项参考](./reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md)。用户明确指定 `record` 命令时,阅读对应的 `reference/record/` 文档。
251
244
 
252
- `data query/get` 默认只读取附件字段。用户需要下载或读取附件时,保持相同 profile 和工作区,对已定位的同一 `app_key`、`record_id` 执行 `qingflow --json data get --download-attachments`,保留 schema,并使用 `file_assets.items[].local_path`。该入口直接处理存储 Cookie、重定向和数据面链接刷新,无需 view_id 或 record get。不要用 curl、requests 或 urllib 直接请求远端 Qingflow 附件 URL,也不要为下载而读取认证配置或打印 token。
245
+ `data query/get` 默认只读取附件字段。用户需要下载或读取附件时,保持相同 profile 和工作区,对已定位的同一 `app_key`、`record_id` 执行 `qingflow --json data get --download-attachments`,保留 schema,并使用 `file_assets.items[].local_path`。该入口直接处理存储 Cookie、重定向和数据面链接刷新。不要用 curl、requests 或 urllib 直接请求远端 Qingflow 附件 URL,也不要为下载而读取认证配置或打印 token。
253
246
 
254
- 普通应用数据使用 `data`。不得改用 `record list/access/get/insert/update/delete` 绕过 data 返回的权限、字段能力或校验结果。附件下载阅读 [data 附件下载](./reference/data/README.md#附件下载与读取);完整日志和详情页专项上下文阅读 [record 专项参考](./reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md)。`record delete` 提交不要传 `custom:*`、`list_type`,细则见 [record 删除参考](./reference/record/QINGFLOW_CLI_RECORD_DELETE_WORKFLOW.md)。
247
+ 附件下载阅读 [data 附件下载](./reference/data/README.md#附件下载与读取)。
255
248
 
256
249
  ## 关联入口
257
250
 
@@ -14,7 +14,7 @@ data query(默认带 schema)
14
14
  → 已知 record_id 时按需 data get
15
15
  ```
16
16
 
17
- 已知 `app_key` 时直接 query;未知时只做必要的 `app list --query`。不要先执行 `app get → record schema browse`,普通数据查询也不需要 `view_id`。
17
+ 已知 `app_key` 时直接 query;未知时先用 `app list --query` 定位应用。
18
18
 
19
19
  ## 1. 首次查询与字段能力
20
20
 
@@ -33,7 +33,7 @@ qingflow --json data query --app-key APP_KEY \
33
33
  - `access`:本次实际访问范围;
34
34
  - `hints`:需要收窄、继续、停止或调整参数的动态提示。
35
35
 
36
- 不要用 insert/update 探测字段能力,也不要从 record schema 推断 data 能力。
36
+ 不要用 insert/update 探测字段能力。
37
37
 
38
38
  ## 2. 字段筛选
39
39
 
@@ -96,7 +96,7 @@ qingflow --json data get --app-key APP_KEY --record-id RECORD_ID \
96
96
  > tmp/qingflow_data_get.json
97
97
  ```
98
98
 
99
- `data get` 默认只读取业务字段。需要下载或读取附件时,对同一 `app_key`、`record_id` 加 `--download-attachments`,保留 schema,并读取 `file_assets.items[].local_path`;无需 view_id 或 record get。不要用 Python/curl 直接请求远端附件 URL。状态、预算与完整流程见 [附件下载与读取](../data/README.md#附件下载与读取);完整日志按 record 专项执行。
99
+ `data get` 默认只读取业务字段。需要下载或读取附件时,对同一 `app_key`、`record_id` 加 `--download-attachments`,保留 schema,并读取 `file_assets.items[].local_path`。不要用 Python/curl 直接请求远端附件 URL。状态、预算与完整流程见 [附件下载与读取](../data/README.md#附件下载与读取);完整日志按 record 专项执行。
100
100
 
101
101
  ## 6. 统计分析
102
102
 
@@ -7,11 +7,22 @@ import sys
7
7
  from typing import Any, Callable, TextIO
8
8
 
9
9
  from ..errors import QingflowApiError, backend_code_value_int, message_looks_like_invalid_token
10
- from ..public_surface import cli_public_tool_spec_from_namespace
10
+ from ..public_surface import (
11
+ NON_TOOL_CLI_HANDLER_ROUTES,
12
+ cli_public_tool_spec_from_namespace,
13
+ cli_route_from_namespace,
14
+ cli_trim_key_from_namespace,
15
+ )
11
16
  from ..resource_uri import cli_resource_base_url
12
17
  from ..response_trim import resolve_cli_tool_name, trim_error_response, trim_public_response
13
18
  from ..tools.ai_builder_tools import _attach_builder_apply_envelope
14
19
  from ..version import get_cli_version, get_cli_version_info
20
+ from ..tool_policy import (
21
+ TOOL_POLICY_ENV,
22
+ TOOL_POLICY_INVALID,
23
+ ToolPolicyViolation,
24
+ parse_tool_policy,
25
+ )
15
26
  from .context import CliContext, build_cli_context
16
27
  from .formatters import emit_json_result, emit_text_result
17
28
  from .commands import register_all_commands
@@ -90,6 +101,14 @@ def run(
90
101
  if handler is None:
91
102
  parser.print_help(out)
92
103
  return 2
104
+ violation = _cli_tool_policy_violation(args)
105
+ if violation is not None:
106
+ return _emit_error(
107
+ violation.payload(),
108
+ json_mode=bool(args.json),
109
+ stdout=out,
110
+ stderr=err,
111
+ )
93
112
  try:
94
113
  if _should_force_json_output(args):
95
114
  setattr(args, "json", True)
@@ -122,6 +141,20 @@ def run(
122
141
  return exit_code
123
142
 
124
143
 
144
+ def _cli_tool_policy_violation(args: argparse.Namespace) -> ToolPolicyViolation | None:
145
+ stable_id = cli_trim_key_from_namespace(args)
146
+ if stable_id is None:
147
+ # 仅显式登记的非业务分发器可跳过闸门;无子命令的新增 handler 也必须默认拒绝。
148
+ if cli_route_from_namespace(args) in NON_TOOL_CLI_HANDLER_ROUTES:
149
+ return None
150
+ return ToolPolicyViolation(
151
+ TOOL_POLICY_INVALID,
152
+ "user:unregistered",
153
+ "CLI 业务命令未登记稳定工具 ID",
154
+ )
155
+ return parse_tool_policy(os.getenv(TOOL_POLICY_ENV)).violation_for(stable_id)
156
+
157
+
125
158
  def _handle_version(_args: argparse.Namespace, _context: CliContext) -> dict[str, Any]:
126
159
  info = get_cli_version_info()
127
160
  return {
@@ -2,11 +2,14 @@ from __future__ import annotations
2
2
 
3
3
  from contextvars import ContextVar
4
4
  from dataclasses import dataclass, field
5
- from typing import Callable
5
+ from typing import TYPE_CHECKING, Callable
6
6
 
7
7
  from .backend_client import BackendRequestContext
8
8
  from .session_store import BackendSession, SessionProfile
9
9
 
10
+ if TYPE_CHECKING:
11
+ from .tool_policy import ToolPolicySnapshot
12
+
10
13
 
11
14
  CLOUD_PROFILE_NAME = "__cloud_mcp__"
12
15
 
@@ -73,3 +76,15 @@ current_call_platform: ContextVar[str] = ContextVar(
73
76
  "qingflow_mcp_current_call_platform",
74
77
  default="",
75
78
  )
79
+
80
+ # 远端 MCP 每个 HTTP 请求独立绑定策略原文;缺头用 None 表示,不回退进程环境变量。
81
+ current_tool_policy_header: ContextVar[str | None] = ContextVar(
82
+ "qingflow_mcp_current_tool_policy_header",
83
+ default=None,
84
+ )
85
+
86
+ # 在 HTTP 入口合成一次快照,列表与调用共用,避免将 URL 选择保存到会话或共享环境。
87
+ current_http_tool_policy: ContextVar[ToolPolicySnapshot | None] = ContextVar(
88
+ "qingflow_mcp_current_http_tool_policy",
89
+ default=None,
90
+ )
@@ -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)
@@ -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
 
@@ -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()}`.
@@ -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,