@qingflow-tech/qingflow-cli 1.1.47 → 1.1.48-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 (54) hide show
  1. package/README.md +4 -4
  2. package/npm/lib/runtime.mjs +29 -13
  3. package/package.json +1 -1
  4. package/pyproject.toml +1 -1
  5. package/skills/qingflow-cli/SKILL.md +91 -396
  6. package/skills/qingflow-cli/manifest.yaml +1 -1
  7. package/skills/qingflow-cli/reference/00-INDEX.md +4 -4
  8. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  9. package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +3 -137
  10. package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
  11. package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +14 -14
  12. package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
  13. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
  14. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +7 -12
  15. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
  16. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  17. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +76 -95
  18. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
  19. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +13 -13
  20. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
  21. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
  22. package/skills/qingflow-cli/reference/data/README.md +32 -0
  23. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_LEGACY_RECORD_RETRIEVAL_WORKFLOW.md +50 -0
  24. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
  25. package/skills/qingflow-cli/reference/record/analysis/README.md +21 -113
  26. package/skills/qingflow-cli/reference/record/analysis/analysis-gotchas.md +10 -10
  27. package/skills/qingflow-cli/reference/record/analysis/analysis-patterns.md +9 -9
  28. package/skills/qingflow-cli/reference/record/analysis/business-context.md +1 -1
  29. package/skills/qingflow-cli/reference/record/analysis/confidence-reporting.md +10 -10
  30. package/skills/qingflow-cli/reference/record/analysis/data-access-playbook.md +52 -80
  31. package/skills/qingflow-cli/reference/record/analysis/pandas-recipes.md +2 -2
  32. package/skills/qingflow-common/SKILL.md +13 -4
  33. package/src/qingflow_mcp/builder_facade/service.py +119 -1
  34. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  35. package/src/qingflow_mcp/cli/commands/auth.py +8 -0
  36. package/src/qingflow_mcp/cli/commands/data.py +134 -0
  37. package/src/qingflow_mcp/cli/commands/record.py +1 -1
  38. package/src/qingflow_mcp/cli/context.py +6 -0
  39. package/src/qingflow_mcp/cli/formatters.py +163 -3
  40. package/src/qingflow_mcp/cli/main.py +4 -1
  41. package/src/qingflow_mcp/config.py +39 -0
  42. package/src/qingflow_mcp/public_surface.py +7 -0
  43. package/src/qingflow_mcp/resource_uri.py +241 -0
  44. package/src/qingflow_mcp/response_trim.py +10 -2
  45. package/src/qingflow_mcp/server.py +4 -0
  46. package/src/qingflow_mcp/server_app_builder.py +16 -0
  47. package/src/qingflow_mcp/server_app_user.py +24 -0
  48. package/src/qingflow_mcp/tools/ai_builder_tools.py +208 -39
  49. package/src/qingflow_mcp/tools/app_tools.py +52 -13
  50. package/src/qingflow_mcp/tools/csv_export.py +168 -0
  51. package/src/qingflow_mcp/tools/data_tools.py +271 -0
  52. package/src/qingflow_mcp/tools/login_link_tools.py +142 -0
  53. package/src/qingflow_mcp/tools/record_tools.py +11 -46
  54. package/src/qingflow_mcp/tools/resource_read_tools.py +13 -0
@@ -2,16 +2,20 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
+ import re
5
6
  from pathlib import Path
6
7
  from typing import Any
7
8
  from urllib.parse import urlsplit, urlunsplit
8
9
 
10
+ from .errors import QingflowApiError
11
+
9
12
  DEFAULT_PROFILE = "default"
10
13
  DEFAULT_TIMEOUT_SECONDS = 30.0
11
14
  DEFAULT_USER_AGENT = "qingflow-mcp/1.0"
12
15
  DEFAULT_RECORD_LIST_TYPE = 8
13
16
  ATTACHMENT_QUESTION_TYPE = 13
14
17
  DEFAULT_BASE_URL = "https://qingflow.com/api"
18
+ DEFAULT_WEB_BASE_URL = "https://qingflow.com"
15
19
  DEFAULT_CLI_OAUTH_RESOURCE_URL = "https://mcp.qingflow.com/mcp"
16
20
  DEFAULT_FEEDBACK_APP_KEY = "e0d017kju002"
17
21
  DEFAULT_FEEDBACK_QSOURCE_TOKEN = "mcp-feedback-7755d14748fc"
@@ -153,6 +157,41 @@ def get_default_base_url() -> str | None:
153
157
  return normalize_base_url(value) if value else None
154
158
 
155
159
 
160
+ def get_web_base_url() -> str:
161
+ """读取网页根地址;空白配置逐级回退,不复用后端或 OAuth 地址。"""
162
+ value = os.getenv("QINGFLOW_MCP_WEB_BASE_URL")
163
+ if value is None or not value.strip():
164
+ value = get_config_value("web_base_url", default=DEFAULT_WEB_BASE_URL)
165
+ if isinstance(value, str) and not value.strip():
166
+ value = DEFAULT_WEB_BASE_URL
167
+ try:
168
+ if not isinstance(value, str):
169
+ raise ValueError
170
+ value = value.strip()
171
+ parsed = urlsplit(value)
172
+ host = parsed.hostname or ""
173
+ # urlsplit 会校验 IPv6;域名标签额外拒绝空格、转义字符及无效边界。
174
+ valid_host = bool(host) and (":" in host or all(
175
+ re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label)
176
+ for label in host.rstrip(".").split(".")
177
+ ))
178
+ if (
179
+ parsed.scheme not in {"http", "https"}
180
+ or not valid_host or host.lower() in {"null", "undefined"}
181
+ or parsed.username is not None or parsed.password is not None
182
+ or parsed.path not in {"", "/"} or "?" in value or "#" in value
183
+ or any(character.isspace() or ord(character) < 32 for character in value)
184
+ ):
185
+ raise ValueError
186
+ # 访问 port 触发 urllib 的端口格式与范围校验。
187
+ _ = parsed.port
188
+ return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
189
+ except ValueError:
190
+ raise QingflowApiError.config_error(
191
+ "QINGFLOW_MCP_WEB_BASE_URL(web_base_url)必须是有效的 HTTP(S) 网页根地址,不包含路径、查询参数或用户凭证"
192
+ ) from None
193
+
194
+
156
195
  def get_default_qf_version() -> str | None:
157
196
  """获取默认的 qfVersion 路由值"""
158
197
  value = get_config_value(
@@ -33,12 +33,18 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
33
33
  PublicToolSpec(USER_DOMAIN, "auth_login", cli_route=("auth", "login"), mcp_public=False),
34
34
  PublicToolSpec(USER_DOMAIN, "auth_use_credential", ("auth_use_credential",), ("auth", "use-credential")),
35
35
  PublicToolSpec(USER_DOMAIN, "auth_whoami", ("auth_whoami",), ("auth", "whoami")),
36
+ PublicToolSpec(USER_DOMAIN, "get_login_link", ("get_login_link",), ("auth", "login-link")),
36
37
  PublicToolSpec(USER_DOMAIN, "auth_logout", ("auth_logout",), ("auth", "logout")),
37
38
  PublicToolSpec(USER_DOMAIN, "workspace_list", ("workspace_list",), ("workspace", "list")),
38
39
  PublicToolSpec(USER_DOMAIN, "workspace_get", ("workspace_get",), ("workspace", "get")),
39
40
  PublicToolSpec(USER_DOMAIN, "workspace_select", ("workspace_select",), ("workspace", "select")),
40
41
  PublicToolSpec(USER_DOMAIN, "app_list", ("app_list",), ("app", "list"), cli_show_effective_context=True),
41
42
  PublicToolSpec(USER_DOMAIN, "app_get", ("app_get",), ("app", "get"), cli_show_effective_context=True),
43
+ PublicToolSpec(USER_DOMAIN, "data_query", ("data_query",), ("data", "query"), cli_show_effective_context=True),
44
+ PublicToolSpec(USER_DOMAIN, "data_get", ("data_get",), ("data", "get"), cli_show_effective_context=True),
45
+ PublicToolSpec(USER_DOMAIN, "data_insert", ("data_insert",), ("data", "insert"), cli_show_effective_context=True, cli_context_write=True),
46
+ PublicToolSpec(USER_DOMAIN, "data_update", ("data_update",), ("data", "update"), cli_show_effective_context=True, cli_context_write=True),
47
+ PublicToolSpec(USER_DOMAIN, "data_delete", ("data_delete",), ("data", "delete"), cli_show_effective_context=True, cli_context_write=True),
42
48
  PublicToolSpec(USER_DOMAIN, "portal_list", ("portal_list",), ("portal", "list"), cli_show_effective_context=True),
43
49
  PublicToolSpec(USER_DOMAIN, "portal_get", ("portal_get",), ("portal", "get"), cli_show_effective_context=True),
44
50
  PublicToolSpec(USER_DOMAIN, "view_get", ("view_get",), ("view", "get"), cli_show_effective_context=True),
@@ -127,6 +133,7 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
127
133
  PublicToolSpec(BUILDER_DOMAIN, "auth_use_credential", ("auth_use_credential",), ("builder", "auth", "use-credential"), cli_public=False),
128
134
  PublicToolSpec(BUILDER_DOMAIN, "auth_whoami", ("auth_whoami",), ("builder", "auth", "whoami"), cli_public=False),
129
135
  PublicToolSpec(BUILDER_DOMAIN, "auth_logout", ("auth_logout",), ("builder", "auth", "logout"), cli_public=False),
136
+ PublicToolSpec(BUILDER_DOMAIN, "get_login_link", ("get_login_link",), mcp_public=True, cli_public=False),
130
137
  PublicToolSpec(BUILDER_DOMAIN, "workspace_list", ("workspace_list",), ("builder", "workspace", "list"), cli_public=False),
131
138
  PublicToolSpec(BUILDER_DOMAIN, "workspace_get", ("workspace_get",), ("builder", "workspace", "get"), cli_public=False),
132
139
  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),
@@ -0,0 +1,241 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+ from contextvars import ContextVar
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+ from urllib.parse import quote, unquote, urlsplit, urlunsplit
10
+
11
+ from .config import DEFAULT_WEB_BASE_URL, get_web_base_url
12
+ from .errors import QingflowApiError
13
+ from .json_types import JSONObject
14
+
15
+
16
+ SYSTEM_VIEW_NAMES = frozenset({"all", "initiated", "todo", "done", "cc"})
17
+ _ENCODED_OCTET = re.compile(r"%[0-9A-Fa-f]{2}")
18
+ _CLI_RESOURCE_BASE_URL: ContextVar[str | None] = ContextVar(
19
+ "qingflow_cli_resource_base_url",
20
+ default=None,
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class ResourcePath:
26
+ resource_type: str
27
+ ws_id: int
28
+ package_id: int | None = None
29
+ app_key: str | None = None
30
+ view_kind: str | None = None
31
+ view_key: str | None = None
32
+
33
+
34
+ @contextmanager
35
+ def cli_resource_base_url(value: str) -> Iterator[None]:
36
+ """让一次 CLI 调用的资源 URI 使用 QINGFLOW_BASE_URL 对应的网页环境。"""
37
+ token = _CLI_RESOURCE_BASE_URL.set(value)
38
+ try:
39
+ yield
40
+ finally:
41
+ _CLI_RESOURCE_BASE_URL.reset(token)
42
+
43
+
44
+ def validate_resource_identity(value: object, field: str = "resource_id") -> str:
45
+ """按资源 URI 单路径段合同校验并返回规范身份,不读取网页域名配置。"""
46
+ return _validate_identity(value, field)
47
+
48
+
49
+ def validate_positive_resource_id(value: object, field: str = "resource_id") -> int:
50
+ """按资源 URI 数字路径段合同校验正整数,不接受布尔值或浮点截断。"""
51
+ return int(_positive_int_segment(value, field))
52
+
53
+
54
+ def build_resource_uri(
55
+ *,
56
+ ws_id: object,
57
+ resource_type: str,
58
+ package_id: object = None,
59
+ app_key: object = None,
60
+ view_kind: str | None = None,
61
+ view_key: object = None,
62
+ web_origin: str | None = None,
63
+ ) -> str:
64
+ """按固定公共合同构造资源 URI,不接受通用目标路由。"""
65
+ workspace = _positive_int_segment(ws_id, "ws_id")
66
+ if resource_type == "package":
67
+ path = f"/resources/ws/{workspace}/package/{_positive_int_segment(package_id, 'package_id')}"
68
+ elif resource_type == "app":
69
+ path = f"/resources/ws/{workspace}/app/{_identity_segment(app_key, 'app_key')}"
70
+ elif resource_type == "view":
71
+ app_segment = _identity_segment(app_key, "app_key")
72
+ if view_kind == "custom":
73
+ view_segment = _identity_segment(view_key, "view_key")
74
+ elif view_kind == "system":
75
+ name = str(view_key or "").strip()
76
+ if name not in SYSTEM_VIEW_NAMES:
77
+ raise ValueError("system view name is not supported")
78
+ view_segment = name
79
+ else:
80
+ raise ValueError("view_kind must be custom or system")
81
+ path = f"/resources/ws/{workspace}/app/{app_segment}/view/{view_kind}/{view_segment}"
82
+ else:
83
+ raise ValueError("resource_type is not supported")
84
+ return f"{resolve_resource_web_origin(web_origin)}{path}"
85
+
86
+
87
+ def parse_resource_path(value: str) -> ResourcePath:
88
+ """严格解析资源 pathname,供免登录签发前校验工作区。"""
89
+ try:
90
+ parsed = urlsplit(value)
91
+ if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment or parsed.path != value:
92
+ raise ValueError
93
+ parts = parsed.path.split("/")
94
+ if len(parts) < 6 or parts[:3] != ["", "resources", "ws"]:
95
+ raise ValueError
96
+ ws_id = int(_decode_segment(parts[3], "ws_id"))
97
+ if ws_id <= 0:
98
+ raise ValueError
99
+ if len(parts) == 6 and parts[4] == "package":
100
+ package_id = int(_decode_segment(parts[5], "package_id"))
101
+ if package_id <= 0:
102
+ raise ValueError
103
+ return ResourcePath("package", ws_id, package_id=package_id)
104
+ if len(parts) == 6 and parts[4] == "app":
105
+ return ResourcePath("app", ws_id, app_key=_decode_segment(parts[5], "app_key"))
106
+ if len(parts) == 9 and parts[4] == "app" and parts[6] == "view":
107
+ app_key = _decode_segment(parts[5], "app_key")
108
+ view_kind = parts[7]
109
+ view_key = _decode_segment(parts[8], "view_key")
110
+ if view_kind == "system" and view_key not in SYSTEM_VIEW_NAMES:
111
+ raise ValueError
112
+ if view_kind not in {"custom", "system"}:
113
+ raise ValueError
114
+ return ResourcePath("view", ws_id, app_key=app_key, view_kind=view_kind, view_key=view_key)
115
+ except (TypeError, ValueError, UnicodeError):
116
+ pass
117
+ raise ValueError("resource path is invalid")
118
+
119
+
120
+ def try_build_resource_uri(**kwargs: Any) -> tuple[str | None, JSONObject | None]:
121
+ try:
122
+ return build_resource_uri(**kwargs), None
123
+ except (QingflowApiError, ValueError, UnicodeError) as error:
124
+ return None, {
125
+ "code": "RESOURCE_URI_UNAVAILABLE",
126
+ "message": f"resource URI was omitted because its identity or web origin is invalid: {error}",
127
+ }
128
+
129
+
130
+ def _positive_int_segment(value: object, field: str) -> str:
131
+ if isinstance(value, bool):
132
+ raise ValueError(f"{field} must be a positive integer")
133
+ try:
134
+ parsed = int(value)
135
+ except (TypeError, ValueError):
136
+ raise ValueError(f"{field} must be a positive integer") from None
137
+ if parsed <= 0 or str(value).strip() != str(parsed):
138
+ raise ValueError(f"{field} must be a positive integer")
139
+ return str(parsed)
140
+
141
+
142
+ def _normalize_web_origin(value: str) -> str:
143
+ try:
144
+ parsed = urlsplit(value.strip())
145
+ if (
146
+ parsed.scheme not in {"http", "https"}
147
+ or not parsed.hostname
148
+ or parsed.username is not None
149
+ or parsed.password is not None
150
+ or parsed.path not in {"", "/"}
151
+ or parsed.query
152
+ or parsed.fragment
153
+ or any(character.isspace() or ord(character) < 32 for character in value)
154
+ ):
155
+ raise ValueError
156
+ _ = parsed.port
157
+ except (AttributeError, ValueError):
158
+ raise ValueError("web_origin must be an absolute HTTP(S) origin") from None
159
+ return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
160
+
161
+
162
+ def resolve_resource_web_origin(web_origin: str | None = None) -> str:
163
+ """解析当前调用面的网页根地址;CLI 与 MCP 分别沿用各自的环境配置。"""
164
+ if web_origin is not None:
165
+ return _normalize_web_origin(web_origin)
166
+ cli_base_url = _CLI_RESOURCE_BASE_URL.get()
167
+ if cli_base_url is None:
168
+ return get_web_base_url()
169
+ return _web_origin_from_api_base_url(cli_base_url)
170
+
171
+
172
+ def _web_origin_from_api_base_url(value: str) -> str:
173
+ normalized = value.strip()
174
+ if not normalized:
175
+ return DEFAULT_WEB_BASE_URL
176
+ try:
177
+ parsed = urlsplit(normalized)
178
+ host = parsed.hostname or ""
179
+ valid_host = bool(host) and (":" in host or all(
180
+ re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label)
181
+ for label in host.rstrip(".").split(".")
182
+ ))
183
+ if (
184
+ parsed.scheme not in {"http", "https"}
185
+ or not valid_host
186
+ or host.lower() in {"null", "undefined"}
187
+ or parsed.username is not None
188
+ or parsed.password is not None
189
+ or parsed.query
190
+ or parsed.fragment
191
+ or any(character.isspace() or ord(character) < 32 for character in normalized)
192
+ ):
193
+ raise ValueError
194
+ _ = parsed.port
195
+ return _normalize_web_origin(urlunsplit((parsed.scheme, parsed.netloc, "", "", "")))
196
+ except ValueError:
197
+ raise QingflowApiError.config_error(
198
+ "QINGFLOW_BASE_URL 必须是有效的 HTTP(S) API 地址,不包含查询参数、片段或用户凭证"
199
+ ) from None
200
+
201
+
202
+ def _identity_segment(value: object, field: str) -> str:
203
+ raw = _validate_identity(value, field)
204
+ return quote(raw, safe="")
205
+
206
+
207
+ def _decode_segment(value: str, field: str) -> str:
208
+ decoded = _validate_identity(unquote(value, errors="strict"), field)
209
+ if quote(decoded, safe="") != value:
210
+ raise ValueError(f"{field} must use canonical path encoding")
211
+ return decoded
212
+
213
+
214
+ def _validate_identity(value: object, field: str) -> str:
215
+ source = str(value or "")
216
+ if any(ord(character) < 32 or ord(character) == 127 for character in source):
217
+ raise ValueError(f"{field} must be one safe path segment")
218
+ raw = source.strip()
219
+ if not raw:
220
+ raise ValueError(f"{field} is required")
221
+ current = raw
222
+ for _ in range(8):
223
+ if (
224
+ current in {".", ".."}
225
+ or any(character in current for character in "/\\?#")
226
+ or any(ord(character) < 32 or ord(character) == 127 for character in current)
227
+ ):
228
+ raise ValueError(f"{field} must be one safe path segment")
229
+ decoded = unquote(current, errors="strict")
230
+ if decoded == current:
231
+ return raw
232
+ current = decoded
233
+ if (
234
+ current in {".", ".."}
235
+ or any(character in current for character in "/\\?#")
236
+ or any(ord(character) < 32 or ord(character) == 127 for character in current)
237
+ ):
238
+ raise ValueError(f"{field} must be one safe path segment")
239
+ if _ENCODED_OCTET.search(current):
240
+ raise ValueError(f"{field} has excessive encoding")
241
+ return raw
@@ -326,7 +326,7 @@ def _trim_workspace_get(payload: JSONObject) -> None:
326
326
 
327
327
  def _trim_app_list_like(payload: JSONObject) -> None:
328
328
  payload.pop("apps", None)
329
- _trim_item_list(payload, "items", allowed=("app_key", "app_name", "package_name"))
329
+ _trim_item_list(payload, "items", allowed=("app_key", "app_name", "package_name", "uri"))
330
330
 
331
331
 
332
332
  def _trim_app_get(payload: JSONObject) -> None:
@@ -334,7 +334,7 @@ def _trim_app_get(payload: JSONObject) -> None:
334
334
  _trim_nested_item_list(
335
335
  payload,
336
336
  ("data", "accessible_views"),
337
- allowed=("view_id", "name", "analysis_supported", "list_supported", "kind"),
337
+ allowed=("view_id", "name", "analysis_supported", "list_supported", "kind", "uri"),
338
338
  )
339
339
 
340
340
 
@@ -1134,6 +1134,13 @@ def _summarize_form_diff(diff: dict[str, Any]) -> dict[str, Any]:
1134
1134
  return summary
1135
1135
 
1136
1136
 
1137
+ def _trim_login_link(payload: JSONObject) -> None:
1138
+ # 临时票据只通过链接交付,必须保留链接和过期时间。
1139
+ for key in list(payload):
1140
+ if key not in {"url", "expires_at"}:
1141
+ payload.pop(key)
1142
+
1143
+
1137
1144
  def _register_policy(domains: tuple[str, ...], names: tuple[str, ...], transform: TransformFn) -> None:
1138
1145
  for domain in domains:
1139
1146
  for name in names:
@@ -1215,6 +1222,7 @@ _register_policy(
1215
1222
  _trim_directory,
1216
1223
  )
1217
1224
  _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("feedback_submit",), _trim_feedback)
1225
+ _register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("get_login_link",), _trim_login_link)
1218
1226
  _register_policy(
1219
1227
  (USER_DOMAIN,),
1220
1228
  (
@@ -7,12 +7,14 @@ from mcp.server.fastmcp import FastMCP
7
7
  from .backend_client import BackendClient
8
8
  from .session_store import SessionStore
9
9
  from .tools.app_tools import AppTools
10
+ from .tools.data_tools import DataTools
10
11
  from .tools.auth_tools import AuthTools
11
12
  from .tools.code_block_tools import CodeBlockTools
12
13
  from .tools.feedback_tools import FeedbackTools
13
14
  from .tools.export_tools import ExportTools
14
15
  from .tools.file_tools import FileTools
15
16
  from .tools.import_tools import ImportTools
17
+ from .tools.login_link_tools import LoginLinkTools
16
18
  from .tools.package_tools import PackageTools
17
19
  from .tools.navigation_tools import NavigationTools
18
20
  from .tools.directory_tools import DirectoryTools
@@ -238,6 +240,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
238
240
  sessions = SessionStore()
239
241
  backend = BackendClient()
240
242
  AuthTools(sessions, backend).register(server)
243
+ LoginLinkTools(sessions, backend).register(server)
241
244
  FeedbackTools(backend, mcp_side="通用").register(server)
242
245
  WorkspaceTools(sessions, backend).register(server)
243
246
  FileTools(sessions, backend).register(server)
@@ -247,6 +250,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
247
250
  TaskContextTools(sessions, backend).register(server)
248
251
  RoleTools(sessions, backend).register(server)
249
252
  AppTools(sessions, backend).register(server)
253
+ DataTools(sessions, backend).register(server)
250
254
  QingbiReportTools(sessions, backend).register(server)
251
255
  PackageTools(sessions, backend).register(server)
252
256
  NavigationTools(sessions, backend).register(server)
@@ -9,6 +9,7 @@ from .config import DEFAULT_PROFILE
9
9
  from .response_trim import BUILDER_SERVER_METHOD_MAP, trim_error_response, trim_public_response, wrap_trimmed_methods
10
10
  from .session_store import SessionStore
11
11
  from .tools.ai_builder_tools import AiBuilderTools
12
+ from .tools.login_link_tools import LOGIN_LINK_DESCRIPTION, LoginLinkTools
12
13
  from .tools.auth_tools import AuthTools
13
14
  from .tools.feedback_tools import FeedbackTools
14
15
  from .tools.file_tools import FileTools
@@ -41,6 +42,16 @@ def build_builder_server() -> FastMCP:
41
42
  "member_search/role_search/role_create when workflow assignees must come from the directory or role catalog, preferring roles over explicit members unless the user explicitly names members, "
42
43
  "then app_form_apply for complete app form targets, explicit app_delete for application deletion, and app_flow_apply/app_views_apply/app_associated_resources_apply/app_charts_apply/portal_apply/portal_delete for non-form patches. app_custom_buttons_apply is maintenance-only for explicit standalone button work outside normal view design. These apply/delete tools perform validation, normalization, writes, and readback verification where applicable. AppForm publishes only after a draft change or when the published form lags behind; views noop requests skip publish, app_custom_buttons_apply and app_associated_resources_apply publish after at least one write succeeds and expose no draft-only parameter, charts are immediate-live without publish and resolve targets by chart_id first then exact unique chart name, portal updates use replace semantics only when sections are supplied and edit-mode base-info-only updates may omit sections, portal delete separates DELETE execution from readback verification, portal pc layout is a 24-column grid and mobile is a 6-column grid so omit position or use layout_preset when unsure, publish=false only guarantees draft/base-info updates for tools that still expose that parameter, and flow should use publish=false whenever you only want draft/precheck behavior. "
43
44
  "Builder apply/write outputs include schema_version, operation, summary, and resources[]; use resources[].id/key/name/ids/parent as the stable UI and agent display entry, and keep legacy fields such as field_diff/views_diff/chart_results only for compatibility or troubleshooting. "
45
+ "创建、修改或安装轻流资源后,最终回复主动提供主要结果的可点击页面入口,无需等待用户再次索要。"
46
+ "采用“完成结果 + 查看入口”,文案使用“在轻流中查看应用/应用包/视图”,不默认称为“继续编辑”。"
47
+ "默认只交付用户请求的主要结果:安装方案默认提供应用包入口;直接创建或修改应用、视图时提供对应入口;用户明确要求多个主要对象时分别提供。"
48
+ "使用业务结果中的稳定 `uri`,按 URI 去重,不为全部子资源预签票据;主要对象没有可靠 URI 时说明限制,不猜地址,也不替换为不相关页面。"
49
+ "部分成功时仅交付已确认成功且可定位的主要结果,保留失败或未验证说明。"
50
+ "纯数据问答、中间读取、失败资源和删除操作不自动签发;用户明确要求不提供链接时遵从。用户明确要求查看或打开页面时也使用已确认的路径。"
51
+ "目标浏览器登录态未知时,临近交付用资源 URI 的同源 pathname 调用 get_login_link,为每个实际展示的入口分别签发,使用返回的完整 URL,不额外追问登录态。"
52
+ "只有已有用户确认同一浏览器、网页域名、账号和工作区已登录且无失效信号的事实时,才直接交付稳定 URI;调用过票据工具、自动化浏览器已登录或之前签发过票据均不能证明用户浏览器已登录,已消费链接不可复用。"
53
+ "用户未要求打开时不提前打开或消费链接;浏览器不可用时提供可点击链接,不假称已经打开。"
54
+ "签发失败时说明免登录链接未生成,提供标注“需登录”的稳定 URI 作为备用入口,不重做业务操作。机器结果继续保留稳定 URI,临时登录链接仅用于面向用户交付。 "
44
55
  "For existing object parameter replacement, prefer patch_views, patch_buttons, patch_resources, and patch_charts with set/unset; the tool reads current config and full-saves internally, while upsert_* is for creation or full target configuration and should not be used as an incomplete partial update. "
45
56
  "For builder delete/remove apply results, separate delete execution from readback verification: after DELETE is sent, resources expose delete_executed, readback_status, and safe_to_retry_delete=false. If readback_status is unavailable or still_exists, do not blindly repeat the delete; confirm later with app_get/view_get/chart_get or the relevant apply readback. Views/buttons use single-item readback; associated resources use one app-level resource-pool readback because there is no confirmed single-item GET. "
46
57
  "For builder write/apply results, separate write execution from final readback verification: status=partial_success with write_executed=true and safe_to_retry=false means the write was sent or succeeded, while final readback, publish verification, or metadata confirmation is pending or permission-restricted. Report it as written but unverified; do not repeat the same write blindly. "
@@ -64,6 +75,7 @@ def build_builder_server() -> FastMCP:
64
75
  workspace = wrap_trimmed_methods(WorkspaceTools(sessions, backend), BUILDER_SERVER_METHOD_MAP)
65
76
  files = wrap_trimmed_methods(FileTools(sessions, backend), BUILDER_SERVER_METHOD_MAP)
66
77
  ai_builder = wrap_trimmed_methods(AiBuilderTools(sessions, backend), BUILDER_SERVER_METHOD_MAP)
78
+ login_link = LoginLinkTools(sessions, backend)
67
79
  feedback = FeedbackTools(backend, mcp_side="App Builder MCP")
68
80
 
69
81
  @server.tool()
@@ -90,6 +102,10 @@ def build_builder_server() -> FastMCP:
90
102
  def auth_logout(profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict:
91
103
  return auth.auth_logout(profile=profile, forget_persisted=forget_persisted)
92
104
 
105
+ @server.tool(description=LOGIN_LINK_DESCRIPTION)
106
+ def get_login_link(profile: str = DEFAULT_PROFILE, redirect_path: str = "") -> dict:
107
+ return login_link.get_login_link(profile=profile, redirect_path=redirect_path)
108
+
93
109
  @server.tool()
94
110
  def workspace_list(
95
111
  profile: str = DEFAULT_PROFILE,
@@ -10,6 +10,7 @@ from .config import DEFAULT_PROFILE
10
10
  from .response_trim import USER_SERVER_METHOD_MAP, trim_error_response, trim_public_response, wrap_trimmed_methods
11
11
  from .session_store import SessionStore
12
12
  from .tools.app_tools import AppTools
13
+ from .tools.data_tools import DataTools
13
14
  from .tools.auth_tools import AuthTools
14
15
  from .tools.code_block_tools import CodeBlockTools
15
16
  from .tools.directory_tools import DirectoryTools
@@ -17,6 +18,7 @@ from .tools.export_tools import ExportTools
17
18
  from .tools.feedback_tools import FeedbackTools
18
19
  from .tools.file_tools import FileTools
19
20
  from .tools.import_tools import ImportTools
21
+ from .tools.login_link_tools import LOGIN_LINK_DESCRIPTION, LoginLinkTools
20
22
  from .tools.resource_read_tools import ResourceReadTools
21
23
  from .tools.task_context_tools import TaskContextTools
22
24
  from .tools.workspace_tools import WorkspaceTools
@@ -30,6 +32,19 @@ def build_user_server(*, stateless_http: bool = False) -> FastMCP:
30
32
  stateless_http=stateless_http,
31
33
  instructions=f"""Use this server for Qingflow operational workflows. Current date: `{today.isoformat()}`.
32
34
 
35
+ ## 页面交接
36
+
37
+ 创建、修改或安装轻流资源后,最终回复主动提供主要结果的可点击页面入口,无需等待用户再次索要。采用“完成结果 + 查看入口”,文案使用“在轻流中查看应用/应用包/视图”,不默认称为“继续编辑”。用户明确要求查看或打开页面时也适用以下规则。
38
+ 默认只交付用户请求的主要结果:安装方案默认提供应用包入口;直接创建或修改应用、视图时提供对应入口;用户明确要求多个主要对象时分别提供。使用业务结果中的稳定 `uri`,按 URI 去重,不为全部子资源预签票据。
39
+ 主要对象没有可靠 URI 时说明限制,不猜地址,也不替换为不相关页面。部分成功时仅交付已确认成功且可定位的主要结果,保留失败或未验证说明。纯数据问答、中间读取、失败资源和删除操作不自动签发;用户明确要求不提供链接时遵从。
40
+ 目标路径由业务工具或领域 Skill 根据真实前端路由确定,不由票据工具根据业务标识推导。
41
+ 已知目标传可选 `redirect_path`;明确指定页面但路径未知时继续定位或说明限制,不猜测,也不静默改为首页。
42
+ 搜索未命中时继续搜索 `login` 或查看服务工具列表,不擅自打开固定正式站点;鉴权或工具失败时如实说明。
43
+ 目标浏览器登录态未知时,默认用资源 URI 的同源 pathname 调用 `get_login_link`,为每个实际展示的入口分别签发,不额外追问登录态;只有已有用户确认同一浏览器、网页域名、账号和工作区已登录且无失效信号的事实时,才直接交付稳定 URI。调用过票据工具、自动化浏览器已登录或之前签发过票据均不能证明用户浏览器已登录,已消费链接也不能复用。
44
+ 临近交付时再签发,使用返回的完整 URL。用户未要求打开时仅交付,不提前消费;要求“打开”时才调用浏览器。签发失败时说明免登录链接未生成,并提供标注“需登录”的稳定 URI 作为备用入口,不重做业务操作。机器结果继续保留稳定 URI,临时登录链接仅用于面向用户交付。
45
+ 浏览器不可用时提供可点击链接,不假称已经打开,不展示工作区数字 ID 或原始 JSON。
46
+ 链接约 5 分钟内一次性有效,不能作为长期书签;安全验证及原有业务权限不变。
47
+
33
48
  ## App Discovery
34
49
 
35
50
  If `app_key` is unknown, use `app_list` first. Pass `query` to filter visible apps by keyword.
@@ -228,6 +243,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
228
243
  backend = BackendClient()
229
244
  auth = wrap_trimmed_methods(AuthTools(sessions, backend), USER_SERVER_METHOD_MAP)
230
245
  apps = wrap_trimmed_methods(AppTools(sessions, backend), USER_SERVER_METHOD_MAP)
246
+ data_tools = wrap_trimmed_methods(DataTools(sessions, backend), USER_SERVER_METHOD_MAP)
231
247
  workspace = wrap_trimmed_methods(WorkspaceTools(sessions, backend), USER_SERVER_METHOD_MAP)
232
248
  file_tools = wrap_trimmed_methods(FileTools(sessions, backend), USER_SERVER_METHOD_MAP)
233
249
  imports = wrap_trimmed_methods(ImportTools(sessions, backend), USER_SERVER_METHOD_MAP)
@@ -237,6 +253,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
237
253
  code_block_tools = wrap_trimmed_methods(CodeBlockTools(sessions, backend), USER_SERVER_METHOD_MAP)
238
254
  task_context_tools = wrap_trimmed_methods(TaskContextTools(sessions, backend), USER_SERVER_METHOD_MAP)
239
255
  directory_tools = wrap_trimmed_methods(DirectoryTools(sessions, backend), USER_SERVER_METHOD_MAP)
256
+ login_link = LoginLinkTools(sessions, backend)
240
257
 
241
258
  @server.tool()
242
259
  def auth_use_credential(
@@ -258,6 +275,11 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
258
275
  def auth_whoami(profile: str = DEFAULT_PROFILE) -> dict:
259
276
  return auth.auth_whoami(profile=profile)
260
277
 
278
+ @server.tool(description=LOGIN_LINK_DESCRIPTION)
279
+ def get_login_link(profile: str = DEFAULT_PROFILE, redirect_path: str = "") -> dict:
280
+ """获取一次性免登录链接;redirect_path 可选,仅接受站内业务路径,省略进入首页。"""
281
+ return login_link.get_login_link(profile=profile, redirect_path=redirect_path)
282
+
261
283
  @server.tool()
262
284
  def auth_logout(profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict:
263
285
  return auth.auth_logout(profile=profile, forget_persisted=forget_persisted)
@@ -375,6 +397,8 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
375
397
  def app_get(profile: str = DEFAULT_PROFILE, app_key: str = "") -> dict:
376
398
  return apps.app_get(profile=profile, app_key=app_key)
377
399
 
400
+ data_tools.register(server)
401
+
378
402
  @server.tool()
379
403
  def portal_list(profile: str = DEFAULT_PROFILE) -> dict:
380
404
  return resources.portal_list(profile=profile)