@qingflow-tech/qingflow-cli 1.1.45 → 1.1.47-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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/SKILL.md +16 -2
- package/skills/qingflow-cli/reference/builder/90-workflow.md +4 -0
- package/skills/qingflow-cli/reference/builder/workflow/01-overview.md +3 -3
- package/skills/qingflow-cli/reference/builder/workflow/02-update-mode.md +2 -0
- package/skills/qingflow-common/SKILL.md +17 -1
- package/src/qingflow_mcp/app_form_validator.py +304 -0
- package/src/qingflow_mcp/builder_facade/service.py +5 -0
- package/src/qingflow_mcp/cli/commands/auth.py +8 -0
- package/src/qingflow_mcp/cli/commands/builder.py +368 -1
- package/src/qingflow_mcp/cli/context.py +3 -0
- package/src/qingflow_mcp/cli/formatters.py +5 -0
- package/src/qingflow_mcp/config.py +39 -0
- package/src/qingflow_mcp/public_surface.py +3 -0
- package/src/qingflow_mcp/response_trim.py +14 -0
- package/src/qingflow_mcp/server.py +2 -0
- package/src/qingflow_mcp/server_app_user.py +17 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +0 -1
- package/src/qingflow_mcp/tools/ai_builder_tools.py +58 -0
- package/src/qingflow_mcp/tools/login_link_tools.py +114 -0
|
@@ -852,6 +852,64 @@ class AiBuilderTools(ToolBase):
|
|
|
852
852
|
"""Reconcile one complete declarative AppForm target."""
|
|
853
853
|
return self._app_form.apply(profile=profile, spec=spec)
|
|
854
854
|
|
|
855
|
+
def app_form_evaluate_formula(
|
|
856
|
+
self,
|
|
857
|
+
*,
|
|
858
|
+
profile: str,
|
|
859
|
+
app_key: str,
|
|
860
|
+
formula: str,
|
|
861
|
+
test_case: dict[int, Any],
|
|
862
|
+
) -> JSONObject:
|
|
863
|
+
"""调用后端 POST /ai/coding/formula/validate 对公式求值。
|
|
864
|
+
|
|
865
|
+
本地语法校验由 CLI handler 负责;本方法只做后端求值,捕获后端错误为 evaluationError。
|
|
866
|
+
鉴权/会话错误(未登录/未选工作区)按既有约定向上抛出 QingflowApiError。
|
|
867
|
+
"""
|
|
868
|
+
_session_profile, _backend_session, context = self._require_context(profile, require_workspace=True)
|
|
869
|
+
evaluation_result: Any = None
|
|
870
|
+
evaluation_error: str | None = None
|
|
871
|
+
try:
|
|
872
|
+
evaluation_result = self.backend.request(
|
|
873
|
+
"POST",
|
|
874
|
+
context,
|
|
875
|
+
"/ai/coding/formula/validate",
|
|
876
|
+
json_body={"appKey": app_key, "formula": formula, "testCase": test_case},
|
|
877
|
+
)
|
|
878
|
+
except QingflowApiError as exc:
|
|
879
|
+
evaluation_error = exc.message
|
|
880
|
+
return {"evaluation": evaluation_result, "evaluationError": evaluation_error}
|
|
881
|
+
|
|
882
|
+
def app_form_run_code_block(
|
|
883
|
+
self,
|
|
884
|
+
*,
|
|
885
|
+
profile: str,
|
|
886
|
+
app_key: str,
|
|
887
|
+
code: str,
|
|
888
|
+
key_que_values: list[JSONObject],
|
|
889
|
+
) -> JSONObject:
|
|
890
|
+
"""调用后端 POST /data/{appKey}/codeBlock/detection 真跑代码块。
|
|
891
|
+
|
|
892
|
+
本地静态校验由 CLI handler 负责;本方法只做后端真跑,捕获后端错误为 runError。
|
|
893
|
+
``key_que_values`` 需符合后端 OpenMatchQueValue 结构 ``{keyQueId, ordinal?, values}``。
|
|
894
|
+
|
|
895
|
+
传入的 ``code`` 是原始 JS(未经前端字段公式加密),必须声明 ``unEncrypted=True``;
|
|
896
|
+
否则后端 ``DataSvcImpl.codeBlockTest`` 会走 ``FormulaDecoder.extractFormula``
|
|
897
|
+
对原始 JS 解密并抛 ``QFRuntimeException``,导致后端真跑对所有真实代码必然失败。
|
|
898
|
+
"""
|
|
899
|
+
_session_profile, _backend_session, context = self._require_context(profile, require_workspace=True)
|
|
900
|
+
run_result: Any = None
|
|
901
|
+
run_error: str | None = None
|
|
902
|
+
try:
|
|
903
|
+
run_result = self.backend.request(
|
|
904
|
+
"POST",
|
|
905
|
+
context,
|
|
906
|
+
f"/data/{app_key}/codeBlock/detection",
|
|
907
|
+
json_body={"code": code, "keyQueValues": key_que_values, "unEncrypted": True},
|
|
908
|
+
)
|
|
909
|
+
except QingflowApiError as exc:
|
|
910
|
+
run_error = exc.message
|
|
911
|
+
return {"runResult": run_result, "runError": run_error}
|
|
912
|
+
|
|
855
913
|
def app_delete(self, *, profile: str, app_key: str) -> JSONObject:
|
|
856
914
|
"""Delete an application explicitly."""
|
|
857
915
|
return self._app_form.delete(profile=profile, app_key=app_key)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import posixpath
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import parse_qs, unquote, urlencode, urlsplit
|
|
6
|
+
|
|
7
|
+
from mcp.server.fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from ..config import DEFAULT_PROFILE, get_web_base_url
|
|
10
|
+
from ..errors import QingflowApiError
|
|
11
|
+
from ..json_types import JSONObject
|
|
12
|
+
from .base import ToolBase
|
|
13
|
+
|
|
14
|
+
LOGIN_LINK_DESCRIPTION = (
|
|
15
|
+
"轻流页面入口:查看页面、继续编辑、处理业务、查看结果或进入工作区(open/view/visit page in browser,login)。"
|
|
16
|
+
"从 AI 对话进入页面时,使用当前 MCP 身份和工作区生成短时(约 5 分钟)一次性免登录链接。"
|
|
17
|
+
"redirect_path 可选:传已确认的站内业务路径,可带 query/hash;省略进入首页,不接受外部地址或认证入口。"
|
|
18
|
+
"纯数据查询无需调用;用户指定页面但路径未知时先定位,不猜路径或静默改为首页。"
|
|
19
|
+
"返回 url 和毫秒整数 expires_at;必要安全验证及原有页面权限仍然生效。"
|
|
20
|
+
"用户要链接时仅交付,不提前消费;要求打开时才用浏览器打开返回的完整链接。"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_redirect_path(value: str) -> str:
|
|
25
|
+
"""只接受业务站内路径;解码仅用于校验,不改写查询参数或片段。"""
|
|
26
|
+
if not value or not value.strip():
|
|
27
|
+
return ""
|
|
28
|
+
auth_paths = (
|
|
29
|
+
"/mcp-login", "/qingclaw-login", "/passport", "/login-third-party", "/third-party-login",
|
|
30
|
+
"/third-party", "/oidc-authorize", "/oauth2/authorize", "/authorize", "/sso", "/ldap",
|
|
31
|
+
"/pcWechatAutoBind", "/code-login", "/work-wechat-error", "/ding-check-ws",
|
|
32
|
+
)
|
|
33
|
+
try:
|
|
34
|
+
path = unquote(urlsplit(value).path, errors="strict")
|
|
35
|
+
if (
|
|
36
|
+
not value.startswith("/") or value.startswith("//") or path.startswith("//")
|
|
37
|
+
or "\\" in value or "\\" in path or any(c in path for c in "%?#")
|
|
38
|
+
or any(ord(c) < 32 or ord(c) == 127 for c in value + path)
|
|
39
|
+
):
|
|
40
|
+
raise ValueError
|
|
41
|
+
normalized = posixpath.normpath(path)
|
|
42
|
+
if any(normalized == p or normalized.startswith(p + "/") for p in auth_paths):
|
|
43
|
+
raise ValueError
|
|
44
|
+
except (ValueError, UnicodeError):
|
|
45
|
+
raise QingflowApiError(category="validation", message="redirect_path 必须是站内业务路径,不能指向认证入口") from None
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LoginLinkTools(ToolBase):
|
|
50
|
+
"""登录链接工具(中文名:临时免登录链接)。
|
|
51
|
+
|
|
52
|
+
类型:会话辅助工具。
|
|
53
|
+
主要职责:
|
|
54
|
+
1. 为当前 MCP 用户签发进入当前工作区网页端的临时免登录链接;
|
|
55
|
+
2. 链接短时(5 分钟)、一次性,不携带 MCP 长期凭证或正式登录 token。
|
|
56
|
+
|
|
57
|
+
安全约束:
|
|
58
|
+
- 用户身份仅来自当前 MCP 会话(业务 token),不接收任何 uid 参数;
|
|
59
|
+
- 目标工作区固定为当前选中的 MCP 工作区,不开放任意跳转地址。
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def register(self, mcp: FastMCP) -> None:
|
|
63
|
+
"""注册当前工具到 MCP 服务。"""
|
|
64
|
+
|
|
65
|
+
@mcp.tool(description=LOGIN_LINK_DESCRIPTION)
|
|
66
|
+
def get_login_link(profile: str = DEFAULT_PROFILE, redirect_path: str = "") -> dict[str, Any]:
|
|
67
|
+
return self.get_login_link(profile=profile, redirect_path=redirect_path)
|
|
68
|
+
|
|
69
|
+
def get_login_link(self, *, profile: str, redirect_path: str = "") -> JSONObject:
|
|
70
|
+
"""执行工具方法逻辑。"""
|
|
71
|
+
|
|
72
|
+
def runner(session_profile, context):
|
|
73
|
+
target = validate_redirect_path(redirect_path)
|
|
74
|
+
web_base_url = get_web_base_url()
|
|
75
|
+
result = self.backend.request(
|
|
76
|
+
"POST",
|
|
77
|
+
context,
|
|
78
|
+
"/mcp/auth/login-link",
|
|
79
|
+
json_body={"wsId": session_profile.selected_ws_id},
|
|
80
|
+
)
|
|
81
|
+
# 后端仅提供票据,网页地址由 MCP 配置确定,避免工作区域名为空。
|
|
82
|
+
try:
|
|
83
|
+
source_url = result.get("url") if isinstance(result, dict) else None
|
|
84
|
+
if not isinstance(source_url, str):
|
|
85
|
+
raise ValueError
|
|
86
|
+
tickets = parse_qs(urlsplit(source_url).query, keep_blank_values=True).get("ticket", [])
|
|
87
|
+
if len(tickets) != 1 or not tickets[0].strip():
|
|
88
|
+
raise ValueError
|
|
89
|
+
# 后端可能把毫秒时间戳序列化为字符串,不能截断浮点数或伪造缺失值。
|
|
90
|
+
expiry = result.get("expiresAt")
|
|
91
|
+
if type(expiry) is int:
|
|
92
|
+
expires_at = expiry
|
|
93
|
+
elif isinstance(expiry, str) and expiry.isascii() and expiry.isdecimal():
|
|
94
|
+
expires_at = int(expiry)
|
|
95
|
+
else:
|
|
96
|
+
raise ValueError
|
|
97
|
+
if expires_at <= 0:
|
|
98
|
+
raise ValueError
|
|
99
|
+
except ValueError:
|
|
100
|
+
# 不把原始响应或票据放入错误信息。
|
|
101
|
+
raise QingflowApiError(
|
|
102
|
+
category="backend",
|
|
103
|
+
message="INVALID_LOGIN_LINK: 后端免登录链接的票据或有效期无效,请重新获取",
|
|
104
|
+
) from None
|
|
105
|
+
query = {"ticket": tickets[0]}
|
|
106
|
+
if target:
|
|
107
|
+
query["redirect_path"] = target
|
|
108
|
+
return {
|
|
109
|
+
"url": f"{web_base_url}/mcp-login?{urlencode(query)}",
|
|
110
|
+
"expires_at": expires_at,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# require_workspace=True:目标工作区恒为当前 MCP 会话选中的工作区
|
|
114
|
+
return self._run(profile, runner, tool_name="获取临时免登录链接")
|