@qingflow-tech/qingflow-cli 1.1.46 → 1.1.47-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.
@@ -10,6 +10,7 @@ from ..config import DEFAULT_PROFILE
10
10
  from ..errors import QingflowApiError, backend_code_int, is_auth_like_error, raise_tool_error
11
11
  from ..json_types import JSONObject
12
12
  from ..list_type_labels import SYSTEM_VIEW_DEFINITIONS, get_app_publish_status_label
13
+ from ..resource_uri import try_build_resource_uri
13
14
  from ..solution.compiler.icon_utils import workspace_icon_config
14
15
  from .base import ToolBase
15
16
 
@@ -46,6 +47,15 @@ class AppTools(ToolBase):
46
47
  unfiltered_count = len(items)
47
48
  if normalized_query:
48
49
  items = self._filter_visible_apps(items, normalized_query)
50
+ for item in items:
51
+ uri, warning = try_build_resource_uri(
52
+ ws_id=session_profile.selected_ws_id,
53
+ resource_type="app",
54
+ app_key=item.get("app_key"),
55
+ )
56
+ if uri:
57
+ item["uri"] = uri
58
+ _append_unique_warning(warnings, warning)
49
59
  response = {
50
60
  "profile": profile,
51
61
  "ws_id": session_profile.selected_ws_id,
@@ -200,9 +210,10 @@ class AppTools(ToolBase):
200
210
  if not _is_optional_app_metadata_error(exc):
201
211
  raise
202
212
  visible_app = self._resolve_visible_app(context, app_key)
203
- if visible_app is not None:
204
- app_name = str(visible_app.get("app_name") or visible_app.get("title") or app_key).strip() or app_key
205
- app_icon = str(visible_app.get("app_icon") or "").strip() or None
213
+ if visible_app is None:
214
+ raise
215
+ app_name = str(visible_app.get("app_name") or visible_app.get("title") or app_key).strip() or app_key
216
+ app_icon = str(visible_app.get("app_icon") or "").strip() or None
206
217
  warnings.append(
207
218
  _with_api_error_fields(
208
219
  {
@@ -224,22 +235,45 @@ class AppTools(ToolBase):
224
235
  accessible_views.extend(custom_views)
225
236
  import_capability, import_warnings = _derive_import_capability(base_info)
226
237
  warnings.extend(import_warnings)
238
+ app_uri, uri_warning = try_build_resource_uri(
239
+ ws_id=session_profile.selected_ws_id,
240
+ resource_type="app",
241
+ app_key=app_key,
242
+ )
243
+ _append_unique_warning(warnings, uri_warning)
244
+ for view in accessible_views:
245
+ view_id = str(view.get("view_id") or "")
246
+ view_kind, separator, view_key = view_id.partition(":")
247
+ if not separator:
248
+ continue
249
+ uri, warning = try_build_resource_uri(
250
+ ws_id=session_profile.selected_ws_id,
251
+ resource_type="view",
252
+ app_key=app_key,
253
+ view_kind=view_kind,
254
+ view_key=view_key,
255
+ )
256
+ if uri:
257
+ view["uri"] = uri
258
+ _append_unique_warning(warnings, warning)
259
+ data: JSONObject = {
260
+ "app_key": app_key,
261
+ "app_name": app_name,
262
+ "app_icon": app_icon,
263
+ "icon_config": workspace_icon_config(app_icon),
264
+ **({"uri": app_uri} if app_uri else {}),
265
+ **({"visible_app": visible_app} if base_info is None and visible_app is not None else {}),
266
+ "can_create": can_create,
267
+ "import_capability": import_capability,
268
+ "accessible_views": accessible_views,
269
+ }
227
270
  return {
228
271
  "profile": profile,
229
272
  "ws_id": session_profile.selected_ws_id,
230
273
  "ok": True,
231
274
  "request_route": {"base_url": context.base_url, "qf_version": context.qf_version},
232
275
  "warnings": warnings,
233
- "data": {
234
- "app_key": app_key,
235
- "app_name": app_name,
236
- "app_icon": app_icon,
237
- "icon_config": workspace_icon_config(app_icon),
238
- **({"visible_app": visible_app} if base_info is None and visible_app is not None else {}),
239
- "can_create": can_create,
240
- "import_capability": import_capability,
241
- "accessible_views": accessible_views,
242
- },
276
+ "data": data,
243
277
  }
244
278
 
245
279
  return self._run(profile, runner, tool_name='应用详情')
@@ -1048,6 +1082,11 @@ def _derive_editability(base_info: Any) -> tuple[JSONObject, list[JSONObject]]:
1048
1082
  }, warnings
1049
1083
 
1050
1084
 
1085
+ def _append_unique_warning(warnings: list[JSONObject], warning: JSONObject | None) -> None:
1086
+ if warning and not any(item.get("code") == warning.get("code") for item in warnings):
1087
+ warnings.append(warning)
1088
+
1089
+
1051
1090
  def _coerce_positive_int(value: Any) -> int | None:
1052
1091
  try:
1053
1092
  number = int(value)
@@ -0,0 +1,134 @@
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 ..resource_uri import parse_resource_path
13
+ from .base import ToolBase
14
+
15
+ LOGIN_LINK_DESCRIPTION = (
16
+ "轻流页面入口:查看页面、继续编辑、处理业务、查看结果或进入工作区(open/view/visit page in browser,login)。"
17
+ "从 AI 对话进入页面时,使用当前 MCP 身份和工作区生成短时(约 5 分钟)一次性免登录链接。"
18
+ "redirect_path 可选:传已确认的站内业务路径,可带 query/hash;省略进入首页,不接受外部地址或认证入口。"
19
+ "纯数据查询无需调用;用户指定页面但路径未知时先定位,不猜路径或静默改为首页。"
20
+ "返回 url 和毫秒整数 expires_at;必要安全验证及原有页面权限仍然生效。"
21
+ "交付应用包、应用、视图或安装结果时,目标浏览器登录态未知则默认用资源 uri 的同源 pathname 独立签发;"
22
+ "只有用户确认同一浏览器、网页域名、账号和工作区已登录时才直接交付稳定 uri。"
23
+ "调用过本工具或 Agent 自动化浏览器已登录不等于用户浏览器已登录;纯中间查询不签发。"
24
+ "用户要链接时仅交付,不提前消费;要求打开时才用浏览器打开返回的完整链接。"
25
+ )
26
+
27
+
28
+ def validate_redirect_path(value: str) -> str:
29
+ """只接受业务站内路径;解码仅用于校验,不改写查询参数或片段。"""
30
+ if not value or not value.strip():
31
+ return ""
32
+ auth_paths = (
33
+ "/mcp-login", "/qingclaw-login", "/passport", "/login-third-party", "/third-party-login",
34
+ "/third-party", "/oidc-authorize", "/oauth2/authorize", "/authorize", "/sso", "/ldap",
35
+ "/pcWechatAutoBind", "/code-login", "/work-wechat-error", "/ding-check-ws",
36
+ )
37
+ try:
38
+ path = unquote(urlsplit(value).path, errors="strict")
39
+ if (
40
+ not value.startswith("/") or value.startswith("//") or path.startswith("//")
41
+ or "\\" in value or "\\" in path or any(c in path for c in "%?#")
42
+ or any(ord(c) < 32 or ord(c) == 127 for c in value + path)
43
+ ):
44
+ raise ValueError
45
+ normalized = posixpath.normpath(path)
46
+ if any(normalized == p or normalized.startswith(p + "/") for p in auth_paths):
47
+ raise ValueError
48
+ except (ValueError, UnicodeError):
49
+ raise QingflowApiError(category="validation", message="redirect_path 必须是站内业务路径,不能指向认证入口") from None
50
+ return value
51
+
52
+
53
+ class LoginLinkTools(ToolBase):
54
+ """登录链接工具(中文名:临时免登录链接)。
55
+
56
+ 类型:会话辅助工具。
57
+ 主要职责:
58
+ 1. 为当前 MCP 用户签发进入当前工作区网页端的临时免登录链接;
59
+ 2. 链接短时(5 分钟)、一次性,不携带 MCP 长期凭证或正式登录 token。
60
+
61
+ 安全约束:
62
+ - 用户身份仅来自当前 MCP 会话(业务 token),不接收任何 uid 参数;
63
+ - 目标工作区固定为当前选中的 MCP 工作区,不开放任意跳转地址。
64
+ """
65
+
66
+ def register(self, mcp: FastMCP) -> None:
67
+ """注册当前工具到 MCP 服务。"""
68
+
69
+ @mcp.tool(description=LOGIN_LINK_DESCRIPTION)
70
+ def get_login_link(profile: str = DEFAULT_PROFILE, redirect_path: str = "") -> dict[str, Any]:
71
+ return self.get_login_link(profile=profile, redirect_path=redirect_path)
72
+
73
+ def get_login_link(self, *, profile: str, redirect_path: str = "") -> JSONObject:
74
+ """执行工具方法逻辑。"""
75
+
76
+ def runner(session_profile, context):
77
+ requested_target = redirect_path or ""
78
+ if requested_target.startswith("/resources"):
79
+ target = requested_target
80
+ try:
81
+ resource = parse_resource_path(target)
82
+ except ValueError:
83
+ raise QingflowApiError(
84
+ category="validation",
85
+ message="redirect_path 必须是合法的稳定资源路径",
86
+ ) from None
87
+ if resource.ws_id != session_profile.selected_ws_id:
88
+ raise QingflowApiError(
89
+ category="validation",
90
+ message="redirect_path 的资源工作区必须与当前选中工作区一致",
91
+ )
92
+ else:
93
+ target = validate_redirect_path(requested_target)
94
+ web_base_url = get_web_base_url()
95
+ result = self.backend.request(
96
+ "POST",
97
+ context,
98
+ "/mcp/auth/login-link",
99
+ json_body={"wsId": session_profile.selected_ws_id},
100
+ )
101
+ # 后端仅提供票据,网页地址由 MCP 配置确定,避免工作区域名为空。
102
+ try:
103
+ source_url = result.get("url") if isinstance(result, dict) else None
104
+ if not isinstance(source_url, str):
105
+ raise ValueError
106
+ tickets = parse_qs(urlsplit(source_url).query, keep_blank_values=True).get("ticket", [])
107
+ if len(tickets) != 1 or not tickets[0].strip():
108
+ raise ValueError
109
+ # 后端可能把毫秒时间戳序列化为字符串,不能截断浮点数或伪造缺失值。
110
+ expiry = result.get("expiresAt")
111
+ if type(expiry) is int:
112
+ expires_at = expiry
113
+ elif isinstance(expiry, str) and expiry.isascii() and expiry.isdecimal():
114
+ expires_at = int(expiry)
115
+ else:
116
+ raise ValueError
117
+ if expires_at <= 0:
118
+ raise ValueError
119
+ except ValueError:
120
+ # 不把原始响应或票据放入错误信息。
121
+ raise QingflowApiError(
122
+ category="backend",
123
+ message="INVALID_LOGIN_LINK: 后端免登录链接的票据或有效期无效,请重新获取",
124
+ ) from None
125
+ query = {"ticket": tickets[0]}
126
+ if target:
127
+ query["redirect_path"] = target
128
+ return {
129
+ "url": f"{web_base_url}/mcp-login?{urlencode(query)}",
130
+ "expires_at": expires_at,
131
+ }
132
+
133
+ # require_workspace=True:目标工作区恒为当前 MCP 会话选中的工作区
134
+ return self._run(profile, runner, tool_name="获取临时免登录链接")
@@ -6,6 +6,7 @@ from typing import Any
6
6
  from ..errors import QingflowApiError, backend_code_int, is_auth_like_error, raise_tool_error
7
7
  from ..json_types import JSONObject
8
8
  from ..list_type_labels import SYSTEM_VIEW_DEFINITIONS
9
+ from ..resource_uri import try_build_resource_uri
9
10
  from ..solution.compiler.icon_utils import workspace_icon_config
10
11
  from .app_tools import _analysis_supported_for_view_type
11
12
  from .base import ToolBase
@@ -227,6 +228,17 @@ class ResourceReadTools(ToolBase):
227
228
  "message": f"view_get supports exporting `{view_id}`, but the export call still needs an explicit `app_key` from the parent app context.",
228
229
  }
229
230
  )
231
+ uri = None
232
+ if resolved_app_key:
233
+ uri, uri_warning = try_build_resource_uri(
234
+ ws_id=session_profile.selected_ws_id,
235
+ resource_type="view",
236
+ app_key=resolved_app_key,
237
+ view_kind="custom",
238
+ view_key=view_key,
239
+ )
240
+ if uri_warning:
241
+ warnings.append(uri_warning)
230
242
  return {
231
243
  "profile": profile,
232
244
  "ws_id": session_profile.selected_ws_id,
@@ -249,6 +261,7 @@ class ResourceReadTools(ToolBase):
249
261
  ],
250
262
  "analysis_supported": _analysis_supported_for_view_type(raw_view_type or None),
251
263
  "export_capability": export_capability,
264
+ **({"uri": uri} if uri else {}),
252
265
  },
253
266
  }
254
267