@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.
- package/README.md +2 -2
- package/docs/local-agent-install.md +70 -11
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/builder_facade/service.py +47 -21
- package/src/qingflow_mcp/cli/commands/auth.py +14 -43
- package/src/qingflow_mcp/cli/commands/task.py +4 -1
- package/src/qingflow_mcp/cli/commands/workspace.py +0 -8
- package/src/qingflow_mcp/cli/formatters.py +0 -21
- package/src/qingflow_mcp/config.py +39 -0
- package/src/qingflow_mcp/errors.py +2 -2
- package/src/qingflow_mcp/public_surface.py +2 -6
- package/src/qingflow_mcp/response_trim.py +1 -8
- package/src/qingflow_mcp/server.py +1 -1
- package/src/qingflow_mcp/server_app_builder.py +4 -28
- package/src/qingflow_mcp/server_app_user.py +4 -28
- package/src/qingflow_mcp/session_store.py +31 -5
- package/src/qingflow_mcp/tools/ai_builder_tools.py +117 -1
- package/src/qingflow_mcp/tools/app_tools.py +51 -1
- package/src/qingflow_mcp/tools/approval_tools.py +82 -1
- package/src/qingflow_mcp/tools/auth_tools.py +258 -288
- package/src/qingflow_mcp/tools/base.py +204 -4
- package/src/qingflow_mcp/tools/code_block_tools.py +21 -0
- package/src/qingflow_mcp/tools/custom_button_tools.py +24 -1
- package/src/qingflow_mcp/tools/directory_tools.py +28 -1
- package/src/qingflow_mcp/tools/feedback_tools.py +8 -0
- package/src/qingflow_mcp/tools/file_tools.py +25 -1
- package/src/qingflow_mcp/tools/import_tools.py +40 -1
- package/src/qingflow_mcp/tools/navigation_tools.py +34 -1
- package/src/qingflow_mcp/tools/package_tools.py +37 -1
- package/src/qingflow_mcp/tools/portal_tools.py +28 -1
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +38 -1
- package/src/qingflow_mcp/tools/record_tools.py +255 -2
- package/src/qingflow_mcp/tools/repository_dev_tools.py +21 -2
- package/src/qingflow_mcp/tools/resource_read_tools.py +23 -1
- package/src/qingflow_mcp/tools/role_tools.py +19 -1
- package/src/qingflow_mcp/tools/solution_tools.py +56 -1
- package/src/qingflow_mcp/tools/task_context_tools.py +205 -6
- package/src/qingflow_mcp/tools/task_tools.py +49 -3
- package/src/qingflow_mcp/tools/view_tools.py +56 -1
- package/src/qingflow_mcp/tools/workflow_tools.py +65 -1
- package/src/qingflow_mcp/tools/workspace_tools.py +14 -225
|
@@ -1,14 +1,33 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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()
|