@josephyan/qingflow-cli 0.2.0-beta.984 → 0.2.0-beta.986

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 (43) hide show
  1. package/README.md +2 -2
  2. package/docs/local-agent-install.md +70 -11
  3. package/package.json +1 -1
  4. package/pyproject.toml +1 -1
  5. package/src/qingflow_mcp/__init__.py +1 -1
  6. package/src/qingflow_mcp/builder_facade/service.py +47 -21
  7. package/src/qingflow_mcp/cli/commands/auth.py +14 -43
  8. package/src/qingflow_mcp/cli/commands/task.py +4 -1
  9. package/src/qingflow_mcp/cli/commands/workspace.py +0 -8
  10. package/src/qingflow_mcp/cli/formatters.py +0 -21
  11. package/src/qingflow_mcp/config.py +39 -0
  12. package/src/qingflow_mcp/errors.py +2 -2
  13. package/src/qingflow_mcp/public_surface.py +2 -6
  14. package/src/qingflow_mcp/response_trim.py +1 -8
  15. package/src/qingflow_mcp/server.py +1 -1
  16. package/src/qingflow_mcp/server_app_builder.py +4 -28
  17. package/src/qingflow_mcp/server_app_user.py +4 -28
  18. package/src/qingflow_mcp/session_store.py +31 -5
  19. package/src/qingflow_mcp/tools/ai_builder_tools.py +117 -1
  20. package/src/qingflow_mcp/tools/app_tools.py +51 -1
  21. package/src/qingflow_mcp/tools/approval_tools.py +82 -1
  22. package/src/qingflow_mcp/tools/auth_tools.py +258 -288
  23. package/src/qingflow_mcp/tools/base.py +204 -4
  24. package/src/qingflow_mcp/tools/code_block_tools.py +21 -0
  25. package/src/qingflow_mcp/tools/custom_button_tools.py +24 -1
  26. package/src/qingflow_mcp/tools/directory_tools.py +28 -1
  27. package/src/qingflow_mcp/tools/feedback_tools.py +8 -0
  28. package/src/qingflow_mcp/tools/file_tools.py +25 -1
  29. package/src/qingflow_mcp/tools/import_tools.py +40 -1
  30. package/src/qingflow_mcp/tools/navigation_tools.py +34 -1
  31. package/src/qingflow_mcp/tools/package_tools.py +37 -1
  32. package/src/qingflow_mcp/tools/portal_tools.py +28 -1
  33. package/src/qingflow_mcp/tools/qingbi_report_tools.py +38 -1
  34. package/src/qingflow_mcp/tools/record_tools.py +255 -2
  35. package/src/qingflow_mcp/tools/repository_dev_tools.py +21 -2
  36. package/src/qingflow_mcp/tools/resource_read_tools.py +23 -1
  37. package/src/qingflow_mcp/tools/role_tools.py +19 -1
  38. package/src/qingflow_mcp/tools/solution_tools.py +56 -1
  39. package/src/qingflow_mcp/tools/task_context_tools.py +205 -6
  40. package/src/qingflow_mcp/tools/task_tools.py +49 -3
  41. package/src/qingflow_mcp/tools/view_tools.py +56 -1
  42. package/src/qingflow_mcp/tools/workflow_tools.py +65 -1
  43. package/src/qingflow_mcp/tools/workspace_tools.py +14 -225
@@ -1,14 +1,33 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Callable, TypeVar
3
+ import inspect
4
+ from contextvars import ContextVar
5
+ from typing import Any, Callable, TypeVar
4
6
 
5
7
  from ..backend_client import BackendRequestContext, BackendClient
8
+ from ..config import (
9
+ get_credit_meter_enabled,
10
+ get_credit_usage_base_url,
11
+ get_credit_usage_path,
12
+ )
6
13
  from ..errors import QingflowApiError, raise_tool_error
7
14
  from ..json_types import JSONObject
8
15
  from ..session_store import BackendSession, SessionProfile, SessionStore
9
16
 
10
17
 
11
18
  T = TypeVar("T")
19
+ _RUN_DEPTH: ContextVar[int] = ContextVar("qingflow_mcp_tool_run_depth", default=0)
20
+
21
+
22
+ def tool_cn_name(name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
23
+ """为 tools 方法声明中文展示名(用于计费 extraInfo 记录)。"""
24
+ normalized = str(name).strip()
25
+
26
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
27
+ setattr(func, "__tool_cn_name__", normalized)
28
+ return func
29
+
30
+ return decorator
12
31
 
13
32
 
14
33
  class ToolBase:
@@ -28,18 +47,52 @@ class ToolBase:
28
47
  context = BackendRequestContext(
29
48
  base_url=backend_session.base_url,
30
49
  token=backend_session.token,
31
- ws_id=session_profile.selected_ws_id if require_workspace else None,
50
+ # Keep the selected workspace in context whenever we know it.
51
+ # Some top-level tools (for example workspace browsing) do not
52
+ # require a workspace to execute, but credit metering still needs
53
+ # a stable wsId when the profile already has one selected.
54
+ ws_id=session_profile.selected_ws_id,
32
55
  qf_version=backend_session.qf_version,
33
56
  qf_version_source=backend_session.qf_version_source,
34
57
  )
35
58
  return session_profile, backend_session, context
36
59
 
37
60
  def _run(self, profile: str, func: Callable[[SessionProfile, BackendRequestContext], T], *, require_workspace: bool = True) -> T:
61
+ depth = _RUN_DEPTH.get()
62
+ depth_token = _RUN_DEPTH.set(depth + 1)
63
+ is_top_level_call = depth == 0
64
+ session_profile: SessionProfile | None = None
65
+ backend_session: BackendSession | None = None
66
+ tool_name = self._resolve_calling_tool_name()
38
67
  try:
39
- session_profile, _, context = self._require_context(profile, require_workspace=require_workspace)
68
+ session_profile, backend_session, context = self._require_context(profile, require_workspace=require_workspace)
69
+ if is_top_level_call:
70
+ self._charge_tool_call(
71
+ profile=profile,
72
+ tool_name=tool_name,
73
+ session_profile=session_profile,
74
+ context=context,
75
+ )
40
76
  return func(session_profile, context)
41
77
  except QingflowApiError as error:
78
+ if (
79
+ error.looks_like_invalid_token()
80
+ and session_profile is not None
81
+ and backend_session is not None
82
+ and self._refresh_session_from_credential(
83
+ profile,
84
+ session_profile=session_profile,
85
+ backend_session=backend_session,
86
+ )
87
+ ):
88
+ try:
89
+ refreshed_profile, _, refreshed_context = self._require_context(profile, require_workspace=require_workspace)
90
+ return func(refreshed_profile, refreshed_context)
91
+ except QingflowApiError as refreshed_error:
92
+ self._handle_error(profile, refreshed_error)
42
93
  self._handle_error(profile, error)
94
+ finally:
95
+ _RUN_DEPTH.reset(depth_token)
43
96
  raise AssertionError("unreachable")
44
97
 
45
98
  def _handle_error(self, profile: str, error: QingflowApiError) -> None:
@@ -47,13 +100,160 @@ class ToolBase:
47
100
  self.sessions.invalidate(profile)
48
101
  error = QingflowApiError(
49
102
  category="auth",
50
- message=f"Qingflow session for profile '{profile}' has expired. Run auth_login again.",
103
+ message=f"Qingflow session for profile '{profile}' has expired. Run auth_use_credential again.",
51
104
  backend_code=error.backend_code,
52
105
  request_id=error.request_id,
53
106
  http_status=error.http_status,
54
107
  )
55
108
  raise_tool_error(error)
56
109
 
110
+ def _refresh_session_from_credential(
111
+ self,
112
+ profile: str,
113
+ *,
114
+ session_profile: SessionProfile,
115
+ backend_session: BackendSession,
116
+ ) -> bool:
117
+ credential = (backend_session.credential or "").strip()
118
+ if not credential:
119
+ return False
120
+ try:
121
+ response = self.backend.public_request_with_meta(
122
+ "POST",
123
+ session_profile.base_url,
124
+ "/mcp/auth/context",
125
+ json_body={"credential": credential},
126
+ qf_version=backend_session.qf_version,
127
+ )
128
+ payload = self._unwrap_context_payload(response.data)
129
+ token = self._normalize_text(payload.get("token"))
130
+ ws_id = self._coerce_positive_int(payload.get("wsId"))
131
+ uid = self._coerce_positive_int(payload.get("uid")) or session_profile.uid
132
+ if not token or ws_id is None:
133
+ return False
134
+ backend_qf_version = self._normalize_text(payload.get("qfVersion")) or response.qf_response_version
135
+ qf_version = backend_qf_version if backend_qf_version is not None else backend_session.qf_version
136
+ qf_version_source = (
137
+ "backend_response"
138
+ if backend_qf_version is not None
139
+ else backend_session.qf_version_source
140
+ )
141
+ base_url = self._normalize_text(payload.get("baseUrl")) or session_profile.base_url
142
+ ws_name = self._normalize_text(payload.get("wsName")) or session_profile.selected_ws_name
143
+ self.sessions.save_session(
144
+ profile=profile,
145
+ base_url=base_url,
146
+ qf_version=qf_version,
147
+ qf_version_source=qf_version_source,
148
+ token=token,
149
+ login_token=None,
150
+ credential=credential,
151
+ uid=uid,
152
+ email=self._normalize_text(payload.get("email")) or session_profile.email,
153
+ nick_name=(
154
+ self._normalize_text(payload.get("nickName"))
155
+ or self._normalize_text(payload.get("displayName"))
156
+ or self._normalize_text(payload.get("name"))
157
+ or session_profile.nick_name
158
+ ),
159
+ persist=session_profile.persisted,
160
+ )
161
+ self.sessions.select_workspace(profile, ws_id=ws_id, ws_name=ws_name)
162
+ return True
163
+ except QingflowApiError:
164
+ return False
165
+
166
+ def _unwrap_context_payload(self, payload: Any) -> dict[str, Any]:
167
+ if not isinstance(payload, dict):
168
+ return {}
169
+ nested = payload.get("data")
170
+ if isinstance(nested, dict):
171
+ return nested
172
+ nested = payload.get("result")
173
+ if isinstance(nested, dict):
174
+ return nested
175
+ return payload
176
+
177
+ def _normalize_text(self, value: Any) -> str | None:
178
+ if value is None:
179
+ return None
180
+ text = str(value).strip()
181
+ return text or None
182
+
183
+ def _coerce_positive_int(self, value: Any) -> int | None:
184
+ if isinstance(value, bool) or value is None:
185
+ return None
186
+ try:
187
+ parsed = int(value)
188
+ except (TypeError, ValueError):
189
+ return None
190
+ return parsed if parsed > 0 else None
191
+
192
+ def _resolve_calling_tool_name(self) -> str:
193
+ frame = inspect.currentframe()
194
+ if frame is None:
195
+ return "unknown_tool"
196
+ caller = frame.f_back.f_back if frame.f_back is not None else None
197
+ try:
198
+ if caller is None:
199
+ return "unknown_tool"
200
+ name = caller.f_code.co_name
201
+ if not name:
202
+ return "unknown_tool"
203
+ method = getattr(self, name, None)
204
+ cn_name = getattr(method, "__tool_cn_name__", None)
205
+ if isinstance(cn_name, str) and cn_name.strip():
206
+ return cn_name.strip()
207
+ return name
208
+ finally:
209
+ del frame
210
+
211
+ def _charge_tool_call(
212
+ self,
213
+ *,
214
+ profile: str,
215
+ tool_name: str,
216
+ session_profile: SessionProfile,
217
+ context: BackendRequestContext,
218
+ ) -> None:
219
+ if not get_credit_meter_enabled():
220
+ return
221
+ if context.ws_id is None:
222
+ raise_tool_error(QingflowApiError(category="payment", message="credit meter requires wsId in current session context"))
223
+
224
+ self._record_credit_usage(
225
+ tool_name=tool_name,
226
+ context=context,
227
+ )
228
+
229
+ def _record_credit_usage(
230
+ self,
231
+ *,
232
+ tool_name: str,
233
+ context: BackendRequestContext,
234
+ ) -> None:
235
+ usage_context = BackendRequestContext(
236
+ base_url=get_credit_usage_base_url() or context.base_url,
237
+ token=context.token,
238
+ ws_id=context.ws_id,
239
+ qf_request_id=context.qf_request_id,
240
+ qf_version=context.qf_version,
241
+ qf_version_source=context.qf_version_source,
242
+ )
243
+ self.backend.request(
244
+ "POST",
245
+ usage_context,
246
+ get_credit_usage_path(),
247
+ json_body={
248
+ "skuType": "MCP",
249
+ "skuName": "MCP",
250
+ "modelName": "MCP",
251
+ "scene": "MCP",
252
+ "aiBiz": "MCP",
253
+ "extraInfo": tool_name,
254
+ },
255
+ )
256
+
57
257
  def _require_dict(self, payload: JSONObject | None, field_name: str = "payload") -> JSONObject:
58
258
  if not isinstance(payload, dict) or not payload:
59
259
  raise_tool_error(QingflowApiError.config_error(f"{field_name} must be a non-empty object"))
@@ -7,6 +7,7 @@ from mcp.server.fastmcp import FastMCP
7
7
  from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE
8
8
  from ..errors import QingflowApiError, raise_tool_error
9
9
  from ..json_types import JSONObject, JSONValue
10
+ from .base import tool_cn_name
10
11
  from .record_tools import (
11
12
  ATTACHMENT_QUE_TYPES,
12
13
  DEPARTMENT_QUE_TYPES,
@@ -33,6 +34,15 @@ SUPPORTED_CODE_BLOCK_ROLES = {1, 2, 3, 5}
33
34
 
34
35
 
35
36
  class CodeBlockTools(RecordTools):
37
+ """代码块工具(中文名:代码块运行与映射)。
38
+
39
+ 类型:记录增强工具。
40
+ 主要职责:
41
+ 1. 读取代码块字段配置与关联关系;
42
+ 2. 执行代码块并处理返回别名;
43
+ 3. 按规则回写目标字段并输出执行摘要。
44
+ """
45
+
36
46
  def _get_code_block_relation_schema(
37
47
  self,
38
48
  profile: str,
@@ -41,6 +51,7 @@ class CodeBlockTools(RecordTools):
41
51
  *,
42
52
  force_refresh: bool,
43
53
  ) -> JSONObject:
54
+ """执行内部辅助逻辑。"""
44
55
  cache_key = (profile, app_key, "code_block_relation_form", 1)
45
56
  if not force_refresh and cache_key in self._form_cache:
46
57
  return self._form_cache[cache_key]
@@ -55,6 +66,7 @@ class CodeBlockTools(RecordTools):
55
66
  return normalized
56
67
 
57
68
  def register(self, mcp: FastMCP) -> None:
69
+ """注册当前工具到 MCP 服务。"""
58
70
  super().register(mcp)
59
71
 
60
72
  @mcp.tool()
@@ -107,6 +119,7 @@ class CodeBlockTools(RecordTools):
107
119
  output_profile=output_profile,
108
120
  )
109
121
 
122
+ @tool_cn_name("代码块 Schema")
110
123
  def record_code_block_schema_get_public(
111
124
  self,
112
125
  *,
@@ -114,6 +127,7 @@ class CodeBlockTools(RecordTools):
114
127
  app_key: str,
115
128
  output_profile: str = "normal",
116
129
  ) -> JSONObject:
130
+ """执行记录相关逻辑。"""
117
131
  if not app_key:
118
132
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
119
133
  normalized_output_profile = self._normalize_public_output_profile(output_profile)
@@ -177,6 +191,7 @@ class CodeBlockTools(RecordTools):
177
191
 
178
192
  return self._run_record_tool(profile, runner)
179
193
 
194
+ @tool_cn_name("运行代码块")
180
195
  def record_code_block_run(
181
196
  self,
182
197
  *,
@@ -194,6 +209,7 @@ class CodeBlockTools(RecordTools):
194
209
  force_refresh_form: bool = False,
195
210
  output_profile: str = "normal",
196
211
  ) -> JSONObject:
212
+ """执行记录相关逻辑。"""
197
213
  normalized_record_id = self._validate_app_and_record(app_key, record_id)
198
214
  normalized_output_profile = self._normalize_public_output_profile(output_profile)
199
215
  if role not in SUPPORTED_CODE_BLOCK_ROLES:
@@ -408,6 +424,7 @@ class CodeBlockTools(RecordTools):
408
424
  role: int,
409
425
  audit_node_id: int | None,
410
426
  ) -> list[JSONObject]:
427
+ """执行内部辅助逻辑。"""
411
428
  last_error: QingflowApiError | None = None
412
429
  for list_type in self._INTERNAL_GET_LIST_TYPE_FALLBACKS:
413
430
  params: JSONObject = {"role": role, "listType": list_type}
@@ -427,6 +444,7 @@ class CodeBlockTools(RecordTools):
427
444
  raise_tool_error(QingflowApiError.config_error("record answers could not be loaded for code-block execution"))
428
445
 
429
446
  def _answers_to_open_match_values(self, answers: list[JSONObject], index: FieldIndex) -> list[JSONObject]:
447
+ """执行内部辅助逻辑。"""
430
448
  values: list[JSONObject] = []
431
449
  for answer in answers:
432
450
  if not isinstance(answer, dict):
@@ -438,6 +456,7 @@ class CodeBlockTools(RecordTools):
438
456
  return values
439
457
 
440
458
  def _answer_to_open_match_value(self, answer: JSONObject, index: FieldIndex) -> JSONObject | None:
459
+ """执行内部辅助逻辑。"""
441
460
  que_id = _coerce_count(answer.get("queId", answer.get("que_id")))
442
461
  if que_id is None or que_id <= 0:
443
462
  return None
@@ -470,6 +489,7 @@ class CodeBlockTools(RecordTools):
470
489
  }
471
490
 
472
491
  def _answer_values_to_code_block_values(self, answer: JSONObject, field: FormField) -> list[str]:
492
+ """执行内部辅助逻辑。"""
473
493
  if field.que_type in RELATION_QUE_TYPES:
474
494
  return _relation_ids_from_answer(answer)
475
495
  raw_values = answer.get("values")
@@ -494,6 +514,7 @@ class CodeBlockTools(RecordTools):
494
514
  role: int,
495
515
  audit_node_id: int | None,
496
516
  ) -> JSONObject:
517
+ """执行内部辅助逻辑。"""
497
518
  if role == 1 and audit_node_id is None:
498
519
  return self._verify_record_write_result(
499
520
  context,
@@ -5,10 +5,20 @@ from copy import deepcopy
5
5
  from ..config import DEFAULT_PROFILE
6
6
  from ..errors import QingflowApiError, raise_tool_error
7
7
  from ..json_types import JSONObject
8
- from .base import ToolBase
8
+ from .base import ToolBase, tool_cn_name
9
9
 
10
10
 
11
11
  class CustomButtonTools(ToolBase):
12
+ """自定义按钮工具(中文名:按钮配置管理)。
13
+
14
+ 类型:应用配置工具。
15
+ 主要职责:
16
+ 1. 查询应用自定义按钮;
17
+ 2. 创建、更新、删除按钮配置;
18
+ 3. 支持草稿与发布态按钮配置维护。
19
+ """
20
+
21
+ @tool_cn_name("自定义按钮列表")
12
22
  def custom_button_list(
13
23
  self,
14
24
  *,
@@ -17,6 +27,7 @@ class CustomButtonTools(ToolBase):
17
27
  being_draft: bool = True,
18
28
  include_raw: bool = False,
19
29
  ) -> JSONObject:
30
+ """执行工具方法逻辑。"""
20
31
  self._require_app_key(app_key)
21
32
 
22
33
  def runner(session_profile, context):
@@ -47,6 +58,7 @@ class CustomButtonTools(ToolBase):
47
58
 
48
59
  return self._run(profile, runner)
49
60
 
61
+ @tool_cn_name("自定义按钮详情")
50
62
  def custom_button_get(
51
63
  self,
52
64
  *,
@@ -56,6 +68,7 @@ class CustomButtonTools(ToolBase):
56
68
  being_draft: bool = True,
57
69
  include_raw: bool = False,
58
70
  ) -> JSONObject:
71
+ """执行工具方法逻辑。"""
59
72
  self._require_app_key(app_key)
60
73
  self._require_button_id(button_id)
61
74
 
@@ -77,7 +90,9 @@ class CustomButtonTools(ToolBase):
77
90
 
78
91
  return self._run(profile, runner)
79
92
 
93
+ @tool_cn_name("创建自定义按钮")
80
94
  def custom_button_create(self, *, profile: str, app_key: str, payload: JSONObject) -> JSONObject:
95
+ """执行工具方法逻辑。"""
81
96
  self._require_app_key(app_key)
82
97
  body = self._require_dict(payload)
83
98
 
@@ -87,7 +102,9 @@ class CustomButtonTools(ToolBase):
87
102
 
88
103
  return self._run(profile, runner)
89
104
 
105
+ @tool_cn_name("更新自定义按钮")
90
106
  def custom_button_update(self, *, profile: str, app_key: str, button_id: int, payload: JSONObject) -> JSONObject:
107
+ """执行工具方法逻辑。"""
91
108
  self._require_app_key(app_key)
92
109
  self._require_button_id(button_id)
93
110
  body = self._require_dict(payload)
@@ -113,7 +130,9 @@ class CustomButtonTools(ToolBase):
113
130
 
114
131
  return self._run(profile, runner)
115
132
 
133
+ @tool_cn_name("删除自定义按钮")
116
134
  def custom_button_delete(self, *, profile: str, app_key: str, button_id: int) -> JSONObject:
135
+ """执行工具方法逻辑。"""
117
136
  self._require_app_key(app_key)
118
137
  self._require_button_id(button_id)
119
138
 
@@ -134,14 +153,17 @@ class CustomButtonTools(ToolBase):
134
153
  return self._run(profile, runner)
135
154
 
136
155
  def _require_app_key(self, app_key: str) -> None:
156
+ """执行内部辅助逻辑。"""
137
157
  if not str(app_key or "").strip():
138
158
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
139
159
 
140
160
  def _require_button_id(self, button_id: int) -> None:
161
+ """执行内部辅助逻辑。"""
141
162
  if not isinstance(button_id, int) or isinstance(button_id, bool) or button_id <= 0:
142
163
  raise_tool_error(QingflowApiError.config_error("button_id must be a positive integer"))
143
164
 
144
165
  def _compact_button_base_info(self, item: dict[str, object]) -> JSONObject:
166
+ """执行内部辅助逻辑。"""
145
167
  creator = item.get("creatorUserInfo") if isinstance(item.get("creatorUserInfo"), dict) else {}
146
168
  return {
147
169
  "button_id": item.get("buttonId"),
@@ -162,6 +184,7 @@ class CustomButtonTools(ToolBase):
162
184
  }
163
185
 
164
186
  def _compact_button_detail(self, item: dict[str, object]) -> JSONObject:
187
+ """执行内部辅助逻辑。"""
165
188
  return {
166
189
  "button_id": item.get("buttonId"),
167
190
  "button_text": item.get("buttonText"),
@@ -7,14 +7,24 @@ from mcp.server.fastmcp import FastMCP
7
7
 
8
8
  from ..config import DEFAULT_PROFILE
9
9
  from ..errors import QingflowApiError, raise_tool_error
10
- from .base import ToolBase
10
+ from .base import ToolBase, tool_cn_name
11
11
 
12
12
 
13
13
  ALLOWED_DIRECTORY_SEARCH_SCOPES = {"MEMBER", "DEPT"}
14
14
 
15
15
 
16
16
  class DirectoryTools(ToolBase):
17
+ """组织架构工具(中文名:通讯录与组织目录)。
18
+
19
+ 类型:组织目录工具。
20
+ 主要职责:
21
+ 1. 搜索用户、部门、外部联系人;
22
+ 2. 列举内部用户与部门层级;
23
+ 3. 为审批/字段候选提供目录侧数据来源。
24
+ """
25
+
17
26
  def register(self, mcp: FastMCP) -> None:
27
+ """注册当前工具到 MCP 服务。"""
18
28
  @mcp.tool()
19
29
  def directory_search(
20
30
  profile: str = DEFAULT_PROFILE,
@@ -122,6 +132,7 @@ class DirectoryTools(ToolBase):
122
132
  simple=simple,
123
133
  )
124
134
 
135
+ @tool_cn_name("通讯录搜索")
125
136
  def directory_search(
126
137
  self,
127
138
  *,
@@ -131,6 +142,7 @@ class DirectoryTools(ToolBase):
131
142
  page_num: int,
132
143
  page_size: int,
133
144
  ) -> dict[str, Any]:
145
+ """执行组织目录相关逻辑。"""
134
146
  normalized_scopes = scopes or ["MEMBER", "DEPT"]
135
147
  invalid_scopes = [scope for scope in normalized_scopes if scope not in ALLOWED_DIRECTORY_SEARCH_SCOPES]
136
148
  if invalid_scopes:
@@ -172,6 +184,7 @@ class DirectoryTools(ToolBase):
172
184
  selection={"query": query, "scopes": normalized_scopes},
173
185
  )
174
186
 
187
+ @tool_cn_name("内部成员列表")
175
188
  def directory_list_internal_users(
176
189
  self,
177
190
  *,
@@ -183,6 +196,7 @@ class DirectoryTools(ToolBase):
183
196
  page_size: int,
184
197
  contain_disable: bool,
185
198
  ) -> dict[str, Any]:
199
+ """执行组织目录相关逻辑。"""
186
200
  def runner(session_profile, context):
187
201
  params: dict[str, Any] = {
188
202
  "pageNum": page_num,
@@ -218,6 +232,7 @@ class DirectoryTools(ToolBase):
218
232
  selection={"keyword": keyword, "department_id": dept_id, "role_id": role_id, "include_disabled": contain_disable},
219
233
  )
220
234
 
235
+ @tool_cn_name("内部成员全量")
221
236
  def directory_list_all_internal_users(
222
237
  self,
223
238
  *,
@@ -229,6 +244,7 @@ class DirectoryTools(ToolBase):
229
244
  contain_disable: bool,
230
245
  max_pages: int,
231
246
  ) -> dict[str, Any]:
247
+ """执行组织目录相关逻辑。"""
232
248
  if page_size <= 0:
233
249
  raise_tool_error(QingflowApiError.config_error("page_size must be positive"))
234
250
  if max_pages <= 0:
@@ -295,6 +311,7 @@ class DirectoryTools(ToolBase):
295
311
  selection={"keyword": keyword, "department_id": dept_id, "role_id": role_id, "include_disabled": contain_disable},
296
312
  )
297
313
 
314
+ @tool_cn_name("内部部门列表")
298
315
  def directory_list_internal_departments(
299
316
  self,
300
317
  *,
@@ -303,6 +320,7 @@ class DirectoryTools(ToolBase):
303
320
  page_num: int,
304
321
  page_size: int,
305
322
  ) -> dict[str, Any]:
323
+ """执行组织目录相关逻辑。"""
306
324
  if page_num <= 0:
307
325
  raise_tool_error(QingflowApiError.config_error("page_num must be positive"))
308
326
  if page_size <= 0:
@@ -377,6 +395,7 @@ class DirectoryTools(ToolBase):
377
395
  selection={"keyword": normalized_keyword},
378
396
  )
379
397
 
398
+ @tool_cn_name("部门树全量")
380
399
  def directory_list_all_departments(
381
400
  self,
382
401
  *,
@@ -385,6 +404,7 @@ class DirectoryTools(ToolBase):
385
404
  max_depth: int,
386
405
  max_items: int,
387
406
  ) -> dict[str, Any]:
407
+ """执行组织目录相关逻辑。"""
388
408
  if max_depth < 0:
389
409
  raise_tool_error(QingflowApiError.config_error("max_depth must be non-negative"))
390
410
  if max_items <= 0:
@@ -427,7 +447,9 @@ class DirectoryTools(ToolBase):
427
447
  selection={"parent_department_id": parent_dept_id, "max_depth": max_depth, "max_items": max_items},
428
448
  )
429
449
 
450
+ @tool_cn_name("子部门列表")
430
451
  def directory_list_sub_departments(self, *, profile: str, parent_dept_id: int | None) -> dict[str, Any]:
452
+ """执行组织目录相关逻辑。"""
431
453
  def runner(session_profile, context):
432
454
  params: dict[str, Any] = {}
433
455
  if parent_dept_id is not None:
@@ -449,6 +471,7 @@ class DirectoryTools(ToolBase):
449
471
  selection={"parent_department_id": parent_dept_id},
450
472
  )
451
473
 
474
+ @tool_cn_name("外部联系人列表")
452
475
  def directory_list_external_members(
453
476
  self,
454
477
  *,
@@ -458,6 +481,7 @@ class DirectoryTools(ToolBase):
458
481
  page_size: int,
459
482
  simple: bool,
460
483
  ) -> dict[str, Any]:
484
+ """执行组织目录相关逻辑。"""
461
485
  def runner(session_profile, context):
462
486
  if simple:
463
487
  result = self.backend.request(
@@ -495,6 +519,7 @@ class DirectoryTools(ToolBase):
495
519
  )
496
520
 
497
521
  def _request_route_payload(self, context) -> dict[str, Any]: # type: ignore[no-untyped-def]
522
+ """执行内部辅助逻辑。"""
498
523
  describe_route = getattr(self.backend, "describe_route", None)
499
524
  if callable(describe_route):
500
525
  payload = describe_route(context)
@@ -514,6 +539,7 @@ class DirectoryTools(ToolBase):
514
539
  pagination: dict[str, Any],
515
540
  selection: dict[str, Any],
516
541
  ) -> dict[str, Any]:
542
+ """执行内部辅助逻辑。"""
517
543
  response = dict(raw)
518
544
  response["ok"] = bool(raw.get("ok", True))
519
545
  response["warnings"] = []
@@ -533,6 +559,7 @@ class DirectoryTools(ToolBase):
533
559
  max_depth: int,
534
560
  max_items: int,
535
561
  ) -> tuple[list[dict[str, Any]], bool, int]:
562
+ """执行内部辅助逻辑。"""
536
563
  queue: deque[tuple[int | None, int]] = deque([(parent_dept_id, 0)])
537
564
  seen_ids: set[int] = set()
538
565
  requested_parents: set[int | None] = set()
@@ -8,6 +8,7 @@ from ..backend_client import BackendClient
8
8
  from ..config import get_feedback_app_key, get_feedback_base_url, get_feedback_qsource_token, normalize_base_url
9
9
  from ..errors import QingflowApiError, raise_tool_error
10
10
  from ..json_types import JSONObject
11
+ from .base import tool_cn_name
11
12
 
12
13
 
13
14
  CATEGORY_MAP = {
@@ -32,6 +33,7 @@ class FeedbackTools:
32
33
  mcp_side: str
33
34
 
34
35
  def register(self, mcp: FastMCP) -> None:
36
+ """注册当前工具到 MCP 服务。"""
35
37
  @mcp.tool()
36
38
  def feedback_submit(
37
39
  category: str = "",
@@ -66,6 +68,7 @@ class FeedbackTools:
66
68
  note=note,
67
69
  )
68
70
 
71
+ @tool_cn_name("提交反馈")
69
72
  def feedback_submit(
70
73
  self,
71
74
  *,
@@ -81,6 +84,7 @@ class FeedbackTools:
81
84
  workflow_node_id: str | int | None,
82
85
  note: str | None,
83
86
  ) -> JSONObject:
87
+ """执行工具方法逻辑。"""
84
88
  qsource_token = get_feedback_qsource_token()
85
89
  if not qsource_token:
86
90
  raise_tool_error(
@@ -161,6 +165,7 @@ class FeedbackTools:
161
165
  workflow_node_id: str | int | None,
162
166
  note: str | None,
163
167
  ) -> JSONObject:
168
+ """执行内部辅助逻辑。"""
164
169
  payload: JSONObject = {
165
170
  "title": self._require_text("title", title),
166
171
  "category": self._normalize_label("category", category, CATEGORY_MAP, required=True),
@@ -193,6 +198,7 @@ class FeedbackTools:
193
198
  return payload
194
199
 
195
200
  def _normalize_label(self, field: str, value: str, mapping: dict[str, str], *, required: bool) -> str:
201
+ """执行内部辅助逻辑。"""
196
202
  text = str(value or "").strip()
197
203
  if not text:
198
204
  if required:
@@ -218,12 +224,14 @@ class FeedbackTools:
218
224
  )
219
225
 
220
226
  def _require_text(self, field: str, value: str) -> str:
227
+ """执行内部辅助逻辑。"""
221
228
  normalized = str(value or "").strip()
222
229
  if not normalized:
223
230
  raise_tool_error(QingflowApiError.config_error(f"{field} is required"))
224
231
  return normalized
225
232
 
226
233
  def _normalize_optional_text(self, value: str | None) -> str | None:
234
+ """执行内部辅助逻辑。"""
227
235
  if value is None:
228
236
  return None
229
237
  normalized = str(value).strip()