@josephyan/qingflow-app-builder-mcp 0.2.0-beta.4 → 0.2.0-beta.41

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 (39) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +3 -1
  4. package/skills/qingflow-app-builder/SKILL.md +154 -22
  5. package/skills/qingflow-app-builder/references/create-app.md +51 -21
  6. package/skills/qingflow-app-builder/references/environments.md +1 -1
  7. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
  8. package/skills/qingflow-app-builder/references/gotchas.md +28 -1
  9. package/skills/qingflow-app-builder/references/solution-playbooks.md +14 -12
  10. package/skills/qingflow-app-builder/references/tool-selection.md +45 -17
  11. package/skills/qingflow-app-builder/references/update-flow.md +112 -25
  12. package/skills/qingflow-app-builder/references/update-layout.md +11 -24
  13. package/skills/qingflow-app-builder/references/update-schema.md +1 -23
  14. package/skills/qingflow-app-builder/references/update-views.md +87 -21
  15. package/src/qingflow_mcp/__init__.py +1 -1
  16. package/src/qingflow_mcp/backend_client.py +189 -0
  17. package/src/qingflow_mcp/builder_facade/models.py +584 -1
  18. package/src/qingflow_mcp/builder_facade/service.py +4698 -262
  19. package/src/qingflow_mcp/config.py +39 -0
  20. package/src/qingflow_mcp/import_store.py +121 -0
  21. package/src/qingflow_mcp/list_type_labels.py +24 -0
  22. package/src/qingflow_mcp/server.py +131 -16
  23. package/src/qingflow_mcp/server_app_builder.py +132 -72
  24. package/src/qingflow_mcp/server_app_user.py +143 -187
  25. package/src/qingflow_mcp/solution/compiler/form_compiler.py +14 -4
  26. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +41 -2
  27. package/src/qingflow_mcp/solution/executor.py +44 -7
  28. package/src/qingflow_mcp/tools/ai_builder_tools.py +1567 -144
  29. package/src/qingflow_mcp/tools/app_tools.py +243 -14
  30. package/src/qingflow_mcp/tools/approval_tools.py +411 -76
  31. package/src/qingflow_mcp/tools/directory_tools.py +203 -31
  32. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  33. package/src/qingflow_mcp/tools/file_tools.py +1 -0
  34. package/src/qingflow_mcp/tools/import_tools.py +1164 -0
  35. package/src/qingflow_mcp/tools/portal_tools.py +31 -0
  36. package/src/qingflow_mcp/tools/record_tools.py +4943 -1025
  37. package/src/qingflow_mcp/tools/task_context_tools.py +1335 -0
  38. package/src/qingflow_mcp/tools/task_tools.py +376 -225
  39. package/src/qingflow_mcp/tools/workflow_tools.py +78 -4
@@ -35,39 +35,39 @@ class DirectoryTools(ToolBase):
35
35
  def directory_list_internal_users(
36
36
  profile: str = DEFAULT_PROFILE,
37
37
  keyword: str | None = None,
38
- dept_id: int | None = None,
38
+ department_id: int | None = None,
39
39
  role_id: int | None = None,
40
40
  page_num: int = 1,
41
41
  page_size: int = 20,
42
- contain_disable: bool = False,
42
+ include_disabled: bool = False,
43
43
  ) -> dict[str, Any]:
44
44
  return self.directory_list_internal_users(
45
45
  profile=profile,
46
46
  keyword=keyword,
47
- dept_id=dept_id,
47
+ dept_id=department_id,
48
48
  role_id=role_id,
49
49
  page_num=page_num,
50
50
  page_size=page_size,
51
- contain_disable=contain_disable,
51
+ contain_disable=include_disabled,
52
52
  )
53
53
 
54
54
  @mcp.tool()
55
55
  def directory_list_all_internal_users(
56
56
  profile: str = DEFAULT_PROFILE,
57
57
  keyword: str | None = None,
58
- dept_id: int | None = None,
58
+ department_id: int | None = None,
59
59
  role_id: int | None = None,
60
60
  page_size: int = 200,
61
- contain_disable: bool = False,
61
+ include_disabled: bool = False,
62
62
  max_pages: int = 100,
63
63
  ) -> dict[str, Any]:
64
64
  return self.directory_list_all_internal_users(
65
65
  profile=profile,
66
66
  keyword=keyword,
67
- dept_id=dept_id,
67
+ dept_id=department_id,
68
68
  role_id=role_id,
69
69
  page_size=page_size,
70
- contain_disable=contain_disable,
70
+ contain_disable=include_disabled,
71
71
  max_pages=max_pages,
72
72
  )
73
73
 
@@ -88,13 +88,13 @@ class DirectoryTools(ToolBase):
88
88
  @mcp.tool()
89
89
  def directory_list_all_departments(
90
90
  profile: str = DEFAULT_PROFILE,
91
- parent_dept_id: int | None = None,
91
+ parent_department_id: int | None = None,
92
92
  max_depth: int = 20,
93
93
  max_items: int = 2000,
94
94
  ) -> dict[str, Any]:
95
95
  return self.directory_list_all_departments(
96
96
  profile=profile,
97
- parent_dept_id=parent_dept_id,
97
+ parent_dept_id=parent_department_id,
98
98
  max_depth=max_depth,
99
99
  max_items=max_items,
100
100
  )
@@ -102,9 +102,9 @@ class DirectoryTools(ToolBase):
102
102
  @mcp.tool()
103
103
  def directory_list_sub_departments(
104
104
  profile: str = DEFAULT_PROFILE,
105
- parent_dept_id: int | None = None,
105
+ parent_department_id: int | None = None,
106
106
  ) -> dict[str, Any]:
107
- return self.directory_list_sub_departments(profile=profile, parent_dept_id=parent_dept_id)
107
+ return self.directory_list_sub_departments(profile=profile, parent_dept_id=parent_department_id)
108
108
 
109
109
  @mcp.tool()
110
110
  def directory_list_external_members(
@@ -150,9 +150,27 @@ class DirectoryTools(ToolBase):
150
150
  "pageSize": page_size,
151
151
  },
152
152
  )
153
- return {"profile": profile, "ws_id": session_profile.selected_ws_id, "result": result}
153
+ return {
154
+ "profile": profile,
155
+ "ws_id": session_profile.selected_ws_id,
156
+ "request_route": self._request_route_payload(context),
157
+ "result": result,
158
+ }
154
159
 
155
- return self._run(profile, runner)
160
+ raw = self._run(profile, runner)
161
+ items = [item for item in _directory_items(raw.get("result")) if isinstance(item, dict)]
162
+ return self._public_directory_response(
163
+ raw,
164
+ items=items,
165
+ pagination={
166
+ "page": page_num,
167
+ "page_size": page_size,
168
+ "returned_items": len(items),
169
+ "reported_total": _coerce_int(_payload_value(raw.get("result"), "total")),
170
+ "page_amount": _coerce_int(_payload_value(raw.get("result"), "pageAmount")),
171
+ },
172
+ selection={"query": query, "scopes": normalized_scopes},
173
+ )
156
174
 
157
175
  def directory_list_internal_users(
158
176
  self,
@@ -178,9 +196,27 @@ class DirectoryTools(ToolBase):
178
196
  if role_id is not None:
179
197
  params["roleId"] = role_id
180
198
  result = self.backend.request("GET", context, "/contact", params=params)
181
- return {"profile": profile, "ws_id": session_profile.selected_ws_id, "result": result}
199
+ return {
200
+ "profile": profile,
201
+ "ws_id": session_profile.selected_ws_id,
202
+ "request_route": self._request_route_payload(context),
203
+ "result": result,
204
+ }
182
205
 
183
- return self._run(profile, runner)
206
+ raw = self._run(profile, runner)
207
+ items = [item for item in _directory_items(raw.get("result")) if isinstance(item, dict)]
208
+ return self._public_directory_response(
209
+ raw,
210
+ items=items,
211
+ pagination={
212
+ "page": page_num,
213
+ "page_size": page_size,
214
+ "returned_items": len(items),
215
+ "reported_total": _coerce_int(_payload_value(raw.get("result"), "total")),
216
+ "page_amount": _coerce_int(_payload_value(raw.get("result"), "pageAmount")),
217
+ },
218
+ selection={"keyword": keyword, "department_id": dept_id, "role_id": role_id, "include_disabled": contain_disable},
219
+ )
184
220
 
185
221
  def directory_list_all_internal_users(
186
222
  self,
@@ -237,6 +273,7 @@ class DirectoryTools(ToolBase):
237
273
  return {
238
274
  "profile": profile,
239
275
  "ws_id": session_profile.selected_ws_id,
276
+ "request_route": self._request_route_payload(context),
240
277
  "items": items,
241
278
  "pagination": {
242
279
  "page_size": page_size,
@@ -250,7 +287,13 @@ class DirectoryTools(ToolBase):
250
287
  },
251
288
  }
252
289
 
253
- return self._run(profile, runner)
290
+ raw = self._run(profile, runner)
291
+ return self._public_directory_response(
292
+ raw,
293
+ items=[item for item in raw.get("items", []) if isinstance(item, dict)],
294
+ pagination=raw.get("pagination", {}),
295
+ selection={"keyword": keyword, "department_id": dept_id, "role_id": role_id, "include_disabled": contain_disable},
296
+ )
254
297
 
255
298
  def directory_list_internal_departments(
256
299
  self,
@@ -260,19 +303,79 @@ class DirectoryTools(ToolBase):
260
303
  page_num: int,
261
304
  page_size: int,
262
305
  ) -> dict[str, Any]:
263
- if not keyword:
264
- raise_tool_error(QingflowApiError.config_error("keyword is required"))
306
+ if page_num <= 0:
307
+ raise_tool_error(QingflowApiError.config_error("page_num must be positive"))
308
+ if page_size <= 0:
309
+ raise_tool_error(QingflowApiError.config_error("page_size must be positive"))
310
+ normalized_keyword = keyword.strip()
311
+
312
+ if not normalized_keyword:
313
+ def runner(session_profile, context):
314
+ fetch_limit = max((page_num + 1) * page_size + 1, page_size + 1)
315
+ items, truncated, deepest_depth = self._walk_department_tree(
316
+ context,
317
+ parent_dept_id=None,
318
+ max_depth=20,
319
+ max_items=fetch_limit,
320
+ )
321
+ start = (page_num - 1) * page_size
322
+ page_items = items[start : start + page_size]
323
+ reported_total = None if truncated else len(items)
324
+ page_amount = None if truncated else ((len(items) + page_size - 1) // page_size if items else 0)
325
+ if truncated and page_items:
326
+ page_amount = max(page_num + 1, (start + len(page_items) + page_size - 1) // page_size)
327
+ return {
328
+ "profile": profile,
329
+ "ws_id": session_profile.selected_ws_id,
330
+ "request_route": self._request_route_payload(context),
331
+ "items": page_items,
332
+ "pagination": {
333
+ "page": page_num,
334
+ "page_size": page_size,
335
+ "returned_items": len(page_items),
336
+ "reported_total": reported_total,
337
+ "page_amount": page_amount,
338
+ "depth_scanned": deepest_depth + 1 if page_items else 0,
339
+ },
340
+ }
341
+
342
+ raw = self._run(profile, runner)
343
+ items = [item for item in raw.get("items", []) if isinstance(item, dict)]
344
+ return self._public_directory_response(
345
+ raw,
346
+ items=items,
347
+ pagination=raw.get("pagination", {}),
348
+ selection={"keyword": None},
349
+ )
265
350
 
266
351
  def runner(session_profile, context):
267
352
  result = self.backend.request(
268
353
  "GET",
269
354
  context,
270
355
  "/contact/deptByPage",
271
- params={"keyword": keyword, "pageNum": page_num, "pageSize": page_size},
356
+ params={"keyword": normalized_keyword, "pageNum": page_num, "pageSize": page_size},
272
357
  )
273
- return {"profile": profile, "ws_id": session_profile.selected_ws_id, "page": result}
358
+ return {
359
+ "profile": profile,
360
+ "ws_id": session_profile.selected_ws_id,
361
+ "request_route": self._request_route_payload(context),
362
+ "page": result,
363
+ }
274
364
 
275
- return self._run(profile, runner)
365
+ raw = self._run(profile, runner)
366
+ items = [item for item in _directory_items(raw.get("page")) if isinstance(item, dict)]
367
+ return self._public_directory_response(
368
+ raw,
369
+ items=items,
370
+ pagination={
371
+ "page": page_num,
372
+ "page_size": page_size,
373
+ "returned_items": len(items),
374
+ "reported_total": _coerce_int(_payload_value(raw.get("page"), "total")),
375
+ "page_amount": _coerce_int(_payload_value(raw.get("page"), "pageAmount")),
376
+ },
377
+ selection={"keyword": normalized_keyword},
378
+ )
276
379
 
277
380
  def directory_list_all_departments(
278
381
  self,
@@ -304,18 +407,25 @@ class DirectoryTools(ToolBase):
304
407
  return {
305
408
  "profile": profile,
306
409
  "ws_id": session_profile.selected_ws_id,
410
+ "request_route": self._request_route_payload(context),
307
411
  "items": items,
308
- "traversal": {
309
- "root_parent_dept_id": parent_dept_id,
412
+ "pagination": {
413
+ "root_parent_department_id": parent_dept_id,
310
414
  "returned_items": len(items),
311
- "max_depth": deepest_depth,
312
- "truncated": truncated,
415
+ "fetched_pages": deepest_depth + 1 if items else 0,
313
416
  "is_complete": not truncated,
417
+ "has_more": truncated,
314
418
  "max_items": max_items,
315
419
  },
316
420
  }
317
421
 
318
- return self._run(profile, runner)
422
+ raw = self._run(profile, runner)
423
+ return self._public_directory_response(
424
+ raw,
425
+ items=[item for item in raw.get("items", []) if isinstance(item, dict)],
426
+ pagination=raw.get("pagination", {}),
427
+ selection={"parent_department_id": parent_dept_id, "max_depth": max_depth, "max_items": max_items},
428
+ )
319
429
 
320
430
  def directory_list_sub_departments(self, *, profile: str, parent_dept_id: int | None) -> dict[str, Any]:
321
431
  def runner(session_profile, context):
@@ -323,9 +433,21 @@ class DirectoryTools(ToolBase):
323
433
  if parent_dept_id is not None:
324
434
  params["parentDeptId"] = parent_dept_id
325
435
  result = self.backend.request("GET", context, "/contact/subDeptList", params=params)
326
- return {"profile": profile, "ws_id": session_profile.selected_ws_id, "items": result}
436
+ return {
437
+ "profile": profile,
438
+ "ws_id": session_profile.selected_ws_id,
439
+ "request_route": self._request_route_payload(context),
440
+ "items": result,
441
+ }
327
442
 
328
- return self._run(profile, runner)
443
+ raw = self._run(profile, runner)
444
+ items = [item for item in raw.get("items", []) if isinstance(item, dict)]
445
+ return self._public_directory_response(
446
+ raw,
447
+ items=items,
448
+ pagination={"returned_items": len(items)},
449
+ selection={"parent_department_id": parent_dept_id},
450
+ )
329
451
 
330
452
  def directory_list_external_members(
331
453
  self,
@@ -349,9 +471,59 @@ class DirectoryTools(ToolBase):
349
471
  if keyword:
350
472
  params["keyword"] = keyword
351
473
  result = self.backend.request("GET", context, "/external/member/pageList", params=params)
352
- return {"profile": profile, "ws_id": session_profile.selected_ws_id, "page": result, "simple": simple}
474
+ return {
475
+ "profile": profile,
476
+ "ws_id": session_profile.selected_ws_id,
477
+ "request_route": self._request_route_payload(context),
478
+ "page": result,
479
+ "simple": simple,
480
+ }
353
481
 
354
- return self._run(profile, runner)
482
+ raw = self._run(profile, runner)
483
+ items = [item for item in _directory_items(raw.get("page")) if isinstance(item, dict)]
484
+ return self._public_directory_response(
485
+ raw,
486
+ items=items,
487
+ pagination={
488
+ "page": page_num,
489
+ "page_size": page_size,
490
+ "returned_items": len(items),
491
+ "reported_total": _coerce_int(_payload_value(raw.get("page"), "total")),
492
+ "page_amount": _coerce_int(_payload_value(raw.get("page"), "pageAmount")),
493
+ },
494
+ selection={"keyword": keyword, "simple": simple},
495
+ )
496
+
497
+ def _request_route_payload(self, context) -> dict[str, Any]: # type: ignore[no-untyped-def]
498
+ describe_route = getattr(self.backend, "describe_route", None)
499
+ if callable(describe_route):
500
+ payload = describe_route(context)
501
+ if isinstance(payload, dict):
502
+ return payload
503
+ return {
504
+ "base_url": getattr(context, "base_url", None),
505
+ "qf_version": getattr(context, "qf_version", None),
506
+ "qf_version_source": getattr(context, "qf_version_source", None) or ("context" if getattr(context, "qf_version", None) else "unknown"),
507
+ }
508
+
509
+ def _public_directory_response(
510
+ self,
511
+ raw: dict[str, Any],
512
+ *,
513
+ items: list[dict[str, Any]],
514
+ pagination: dict[str, Any],
515
+ selection: dict[str, Any],
516
+ ) -> dict[str, Any]:
517
+ response = dict(raw)
518
+ response["ok"] = bool(raw.get("ok", True))
519
+ response["warnings"] = []
520
+ response["output_profile"] = "normal"
521
+ response["data"] = {
522
+ "items": items,
523
+ "pagination": pagination,
524
+ "selection": selection,
525
+ }
526
+ return response
355
527
 
356
528
  def _walk_department_tree(
357
529
  self,
@@ -0,0 +1,230 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from ..backend_client import BackendClient
8
+ from ..config import get_feedback_app_key, get_feedback_base_url, get_feedback_qsource_token, normalize_base_url
9
+ from ..errors import QingflowApiError, raise_tool_error
10
+ from ..json_types import JSONObject
11
+
12
+
13
+ CATEGORY_MAP = {
14
+ "feature_request": "功能需求",
15
+ "bug_report": "问题反馈",
16
+ "ux_feedback": "体验建议",
17
+ "unsupported_scenario": "不支持场景",
18
+ "other": "其他",
19
+ }
20
+
21
+ IMPACT_SCOPE_MAP = {
22
+ "personal": "仅个人",
23
+ "small_team": "小范围团队",
24
+ "cross_team": "跨团队",
25
+ "global": "全局",
26
+ }
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class FeedbackTools:
31
+ backend: BackendClient
32
+ mcp_side: str
33
+
34
+ def register(self, mcp: FastMCP) -> None:
35
+ @mcp.tool()
36
+ def feedback_submit(
37
+ category: str = "",
38
+ title: str = "",
39
+ description: str = "",
40
+ expected_behavior: str | None = None,
41
+ actual_behavior: str | None = None,
42
+ impact_scope: str | None = None,
43
+ tool_name: str | None = None,
44
+ app_key: str | None = None,
45
+ record_id: str | int | None = None,
46
+ workflow_node_id: str | int | None = None,
47
+ note: str | None = None,
48
+ ) -> JSONObject:
49
+ """Submit product feedback to the Qingflow MCP team.
50
+
51
+ Use this when the current MCP capability is unsupported, awkward, or still cannot satisfy the user's need
52
+ after reasonable attempts. This helper writes through the internal q-source feedback intake, does not
53
+ require Qingflow login or workspace selection, and should be called only after explicit user confirmation.
54
+ """
55
+ return self.feedback_submit(
56
+ category=category,
57
+ title=title,
58
+ description=description,
59
+ expected_behavior=expected_behavior,
60
+ actual_behavior=actual_behavior,
61
+ impact_scope=impact_scope,
62
+ tool_name=tool_name,
63
+ app_key=app_key,
64
+ record_id=record_id,
65
+ workflow_node_id=workflow_node_id,
66
+ note=note,
67
+ )
68
+
69
+ def feedback_submit(
70
+ self,
71
+ *,
72
+ category: str,
73
+ title: str,
74
+ description: str,
75
+ expected_behavior: str | None,
76
+ actual_behavior: str | None,
77
+ impact_scope: str | None,
78
+ tool_name: str | None,
79
+ app_key: str | None,
80
+ record_id: str | int | None,
81
+ workflow_node_id: str | int | None,
82
+ note: str | None,
83
+ ) -> JSONObject:
84
+ qsource_token = get_feedback_qsource_token()
85
+ if not qsource_token:
86
+ raise_tool_error(
87
+ QingflowApiError(
88
+ category="config",
89
+ message=(
90
+ "feedback_submit is not configured. Set "
91
+ "feedback.qsource_token or QINGFLOW_MCP_FEEDBACK_QSOURCE_TOKEN first."
92
+ ),
93
+ details={"error_code": "FEEDBACK_NOT_CONFIGURED"},
94
+ )
95
+ )
96
+
97
+ base_url = get_feedback_base_url()
98
+ if not base_url:
99
+ raise_tool_error(
100
+ QingflowApiError.config_error(
101
+ "feedback_submit requires a base_url. Configure feedback.base_url or default_base_url."
102
+ )
103
+ )
104
+
105
+ normalized_payload = self._build_payload(
106
+ category=category,
107
+ title=title,
108
+ description=description,
109
+ expected_behavior=expected_behavior,
110
+ actual_behavior=actual_behavior,
111
+ impact_scope=impact_scope,
112
+ tool_name=tool_name,
113
+ app_key=app_key,
114
+ record_id=record_id,
115
+ workflow_node_id=workflow_node_id,
116
+ note=note,
117
+ )
118
+
119
+ try:
120
+ response = self.backend.public_request_with_meta(
121
+ "POST",
122
+ base_url,
123
+ f"/qsource/{qsource_token}",
124
+ json_body=normalized_payload,
125
+ unwrap=True,
126
+ qf_version=None,
127
+ )
128
+ except QingflowApiError as exc:
129
+ raise_tool_error(exc)
130
+ result = response.data if isinstance(response.data, dict) else {}
131
+ feedback_request_id = result.get("requestId") if isinstance(result, dict) else None
132
+
133
+ return {
134
+ "ok": True,
135
+ "request_route": {
136
+ "base_url": normalize_base_url(base_url) or base_url,
137
+ "qf_version": None,
138
+ "qf_version_source": "not_applicable",
139
+ },
140
+ "submission_mode": "qsource_passive",
141
+ "feedback_target": {
142
+ "app_key": get_feedback_app_key(),
143
+ "mcp_side": self.mcp_side,
144
+ },
145
+ "normalized_payload": normalized_payload,
146
+ "feedback_request_id": feedback_request_id,
147
+ }
148
+
149
+ def _build_payload(
150
+ self,
151
+ *,
152
+ category: str,
153
+ title: str,
154
+ description: str,
155
+ expected_behavior: str | None,
156
+ actual_behavior: str | None,
157
+ impact_scope: str | None,
158
+ tool_name: str | None,
159
+ app_key: str | None,
160
+ record_id: str | int | None,
161
+ workflow_node_id: str | int | None,
162
+ note: str | None,
163
+ ) -> JSONObject:
164
+ payload: JSONObject = {
165
+ "title": self._require_text("title", title),
166
+ "category": self._normalize_label("category", category, CATEGORY_MAP, required=True),
167
+ "description": self._require_text("description", description),
168
+ "submit_method": "AI代提",
169
+ "status": "待处理",
170
+ "mcp_side": self.mcp_side,
171
+ }
172
+
173
+ optional_text = {
174
+ "expected_result": expected_behavior,
175
+ "actual_behavior": actual_behavior,
176
+ "tool_name": tool_name,
177
+ "app_key": app_key,
178
+ "note": note,
179
+ }
180
+ for key, value in optional_text.items():
181
+ normalized = self._normalize_optional_text(value)
182
+ if normalized is not None:
183
+ payload[key] = normalized
184
+
185
+ if impact_scope is not None and str(impact_scope).strip():
186
+ payload["impact_scope"] = self._normalize_label("impact_scope", impact_scope, IMPACT_SCOPE_MAP, required=False)
187
+
188
+ if record_id is not None and str(record_id).strip():
189
+ payload["record_id"] = str(record_id).strip()
190
+ if workflow_node_id is not None and str(workflow_node_id).strip():
191
+ payload["workflow_node_id"] = str(workflow_node_id).strip()
192
+
193
+ return payload
194
+
195
+ def _normalize_label(self, field: str, value: str, mapping: dict[str, str], *, required: bool) -> str:
196
+ text = str(value or "").strip()
197
+ if not text:
198
+ if required:
199
+ raise_tool_error(QingflowApiError.config_error(f"{field} is required"))
200
+ return ""
201
+
202
+ canonical = text.lower()
203
+ if canonical in mapping:
204
+ return mapping[canonical]
205
+ if text in mapping.values():
206
+ return text
207
+ supported_values = list(mapping.keys()) + list(mapping.values())
208
+ raise_tool_error(
209
+ QingflowApiError(
210
+ category="config",
211
+ message=f"{field} must be one of the supported canonical values or labels",
212
+ details={
213
+ "error_code": "FEEDBACK_INVALID_INPUT",
214
+ "field": field,
215
+ "supported_values": supported_values,
216
+ },
217
+ )
218
+ )
219
+
220
+ def _require_text(self, field: str, value: str) -> str:
221
+ normalized = str(value or "").strip()
222
+ if not normalized:
223
+ raise_tool_error(QingflowApiError.config_error(f"{field} is required"))
224
+ return normalized
225
+
226
+ def _normalize_optional_text(self, value: str | None) -> str | None:
227
+ if value is None:
228
+ return None
229
+ normalized = str(value).strip()
230
+ return normalized or None
@@ -178,6 +178,7 @@ class FileTools(ToolBase):
178
178
  "download_url": download_url,
179
179
  "attachment_value": {
180
180
  "value": download_url,
181
+ "otherInfo": file_name,
181
182
  "name": file_name,
182
183
  },
183
184
  "comment_file_info": {