@qingflow-tech/qingflow-cli 1.1.61-beta.1 → 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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/SKILL.md +5 -5
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +3 -3
- package/src/qingflow_mcp/cli/main.py +34 -1
- package/src/qingflow_mcp/cloud_context.py +16 -1
- package/src/qingflow_mcp/public_surface.py +67 -8
- package/src/qingflow_mcp/server.py +12 -6
- package/src/qingflow_mcp/server_app_builder.py +9 -5
- package/src/qingflow_mcp/server_app_user.py +9 -5
- package/src/qingflow_mcp/server_cloud.py +43 -7
- package/src/qingflow_mcp/tool_catalog.py +387 -0
- package/src/qingflow_mcp/tool_catalog_i18n.py +185 -0
- package/src/qingflow_mcp/tool_policy.py +197 -0
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.
|
|
10
|
+
npm install @qingflow-tech/qingflow-cli@1.1.61-beta.2
|
|
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.
|
|
24
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.61-beta.2 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.
|
|
65
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.61-beta.2 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.
|
|
3
|
+
"version": "1.1.61-beta.2",
|
|
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
|
@@ -242,16 +242,16 @@ qingflow --json data query \
|
|
|
242
242
|
|
|
243
243
|
- `data insert/update/delete` 是普通应用数据直接写入入口。写入前用只读 `data query/get` 获取 schema、字段能力与访问范围;删除、覆盖已有值、批量写入或其他不可逆操作必须先确认目标和影响范围。
|
|
244
244
|
- `task`、builder、import 均按路由表进入专项文档;主 Skill 不复制其参数、枚举或完整 SOP。
|
|
245
|
-
-
|
|
245
|
+
- 导出/下载只有用户明确需要文件时才使用,并按对应命令的参数执行;不要把导出当作普通查询或统计分析默认入口。
|
|
246
246
|
- 查看视图时,`baseInfo` 或 `viewConfig` 只有 40002 / 40027 / 404 这类权限/缺失边界可用另一侧降级,非权限类后端错误不隐藏;完整规则见 Builder 视图文档。
|
|
247
247
|
|
|
248
|
-
##
|
|
248
|
+
## 专项上下文
|
|
249
249
|
|
|
250
|
-
|
|
250
|
+
详情页引用、关联资源、完整数据/流程日志或代码块字段执行见 [record 专项参考](./reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md)。用户明确指定 `record` 命令时,阅读对应的 `reference/record/` 文档。
|
|
251
251
|
|
|
252
|
-
`data query/get` 默认只读取附件字段。用户需要下载或读取附件时,保持相同 profile 和工作区,对已定位的同一 `app_key`、`record_id` 执行 `qingflow --json data get --download-attachments`,保留 schema,并使用 `file_assets.items[].local_path`。该入口直接处理存储 Cookie
|
|
252
|
+
`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
253
|
|
|
254
|
-
|
|
254
|
+
附件下载阅读 [data 附件下载](./reference/data/README.md#附件下载与读取)。
|
|
255
255
|
|
|
256
256
|
## 关联入口
|
|
257
257
|
|
|
@@ -14,7 +14,7 @@ data query(默认带 schema)
|
|
|
14
14
|
→ 已知 record_id 时按需 data get
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
已知 `app_key` 时直接 query
|
|
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
|
|
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
|
|
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
|
|
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",),
|
|
134
|
-
PublicToolSpec(BUILDER_DOMAIN, "auth_whoami", ("auth_whoami",),
|
|
135
|
-
PublicToolSpec(BUILDER_DOMAIN, "auth_logout", ("auth_logout",),
|
|
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",),
|
|
138
|
-
PublicToolSpec(BUILDER_DOMAIN, "workspace_get", ("workspace_get",),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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() ->
|
|
51
|
+
def build_server() -> PolicyAwareFastMCP:
|
|
52
52
|
today = date.today()
|
|
53
53
|
current_year = today.year
|
|
54
|
-
server =
|
|
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
|
|
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() ->
|
|
31
|
-
server =
|
|
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
|
|
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) ->
|
|
27
|
+
def build_user_server(*, stateless_http: bool = False) -> PolicyAwareFastMCP:
|
|
28
28
|
today = date.today()
|
|
29
29
|
current_year = today.year
|
|
30
|
-
server =
|
|
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
|
|
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
|
|
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() ->
|
|
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
|
|
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(
|
|
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(
|
|
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,
|