@josephyan/qingflow-app-builder-mcp 0.2.0-beta.31 → 0.2.0-beta.33

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 CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.31
6
+ npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.33
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.31 qingflow-app-builder-mcp
12
+ npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.33 qingflow-app-builder-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-builder-mcp",
3
- "version": "0.2.0-beta.31",
3
+ "version": "0.2.0-beta.33",
4
4
  "description": "Builder MCP for Qingflow app/package/system design and staged solution workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.2.0b31"
7
+ version = "0.2.0b33"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -63,6 +63,7 @@ Note:
63
63
  - use `member_search` only when the user explicitly names members or no stable role exists
64
64
  - use `role_create` when the business owner wants a reusable directory role instead of hard-coded members
65
65
  - On any `VALIDATION_ERROR`, inspect `suggested_next_call` first and prefer reusing the MCP-normalized arguments over re-guessing from the original natural language.
66
+ - If the current MCP capability is unsupported, the workflow is awkward, or the user's need still cannot be satisfied after reasonable use, summarize the gap, ask whether to submit feedback, and call `feedback_submit` only after explicit user confirmation.
66
67
 
67
68
  Default policy:
68
69
 
@@ -2,4 +2,4 @@ from __future__ import annotations
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0b31"
5
+ __version__ = "0.2.0b33"
@@ -12,6 +12,7 @@ DEFAULT_USER_AGENT = "qingflow-mcp/1.0"
12
12
  DEFAULT_RECORD_LIST_TYPE = 8
13
13
  ATTACHMENT_QUESTION_TYPE = 13
14
14
  DEFAULT_BASE_URL = "https://qingflow.com/api"
15
+ DEFAULT_FEEDBACK_APP_KEY = "e0d017kju002"
15
16
 
16
17
 
17
18
  def get_mcp_home() -> Path:
@@ -136,6 +137,43 @@ def get_default_qf_version() -> str | None:
136
137
  return normalized or None
137
138
 
138
139
 
140
+ def get_feedback_qsource_token() -> str | None:
141
+ """获取反馈 q-source 被动入口 token"""
142
+ value = get_config_value(
143
+ "feedback.qsource_token",
144
+ env_var="QINGFLOW_MCP_FEEDBACK_QSOURCE_TOKEN",
145
+ default=None,
146
+ )
147
+ if value is None:
148
+ return None
149
+ normalized = str(value).strip()
150
+ return normalized or None
151
+
152
+
153
+ def get_feedback_base_url() -> str | None:
154
+ """获取反馈 q-source 使用的 base URL"""
155
+ value = get_config_value(
156
+ "feedback.base_url",
157
+ env_var="QINGFLOW_MCP_FEEDBACK_BASE_URL",
158
+ default=None,
159
+ )
160
+ if value is None:
161
+ return get_default_base_url()
162
+ normalized = normalize_base_url(value)
163
+ return normalized or get_default_base_url()
164
+
165
+
166
+ def get_feedback_app_key() -> str:
167
+ """获取内部反馈表 app_key"""
168
+ value = get_config_value(
169
+ "feedback.app_key",
170
+ env_var="QINGFLOW_MCP_FEEDBACK_APP_KEY",
171
+ default=DEFAULT_FEEDBACK_APP_KEY,
172
+ )
173
+ normalized = str(value or "").strip()
174
+ return normalized or DEFAULT_FEEDBACK_APP_KEY
175
+
176
+
139
177
  def get_timeout_seconds() -> float:
140
178
  """获取 HTTP 超时秒数"""
141
179
  value = get_config_value(
@@ -8,6 +8,7 @@ from .backend_client import BackendClient
8
8
  from .session_store import SessionStore
9
9
  from .tools.app_tools import AppTools
10
10
  from .tools.auth_tools import AuthTools
11
+ from .tools.feedback_tools import FeedbackTools
11
12
  from .tools.file_tools import FileTools
12
13
  from .tools.package_tools import PackageTools
13
14
  from .tools.navigation_tools import NavigationTools
@@ -17,6 +18,7 @@ from .tools.qingbi_report_tools import QingbiReportTools
17
18
  from .tools.record_tools import RecordTools
18
19
  from .tools.role_tools import RoleTools
19
20
  from .tools.solution_tools import SolutionTools
21
+ from .tools.task_context_tools import TaskContextTools
20
22
  from .tools.view_tools import ViewTools
21
23
  from .tools.workflow_tools import WorkflowTools
22
24
  from .tools.workspace_tools import WorkspaceTools
@@ -93,6 +95,14 @@ Analysis answers must include concrete numbers. When applicable, include percent
93
95
  - If a member or department field id is known but candidate ids are not, use `record_member_candidates` or `record_department_candidates` before `record_write`.
94
96
  - For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
95
97
 
98
+ ## Task Workflow Path
99
+
100
+ `task_list -> task_get -> task_action_execute`
101
+
102
+ - Use `task_associated_report_detail_get` for associated view or report details.
103
+ - Use `task_workflow_log_get` for full workflow log history.
104
+ - Task actions operate on `app_key + record_id + workflow_node_id`, not `task_id`.
105
+
96
106
  ## Time Handling
97
107
 
98
108
  Normalize relative dates before building DSL.
@@ -107,14 +117,24 @@ Default to `prod` unless the user explicitly specifies `test`.
107
117
 
108
118
  ## Constraints
109
119
 
110
- Avoid builder-side app or schema changes here.""",
120
+ Avoid builder-side app or schema changes here.
121
+
122
+ ## Feedback Path
123
+
124
+ If the current MCP capability is unsupported, the workflow is awkward, or the user's need still cannot be satisfied after reasonable use, offer to submit product feedback.
125
+
126
+ - First summarize what is still not working
127
+ - Ask the user whether to submit feedback
128
+ - Call `feedback_submit` only after explicit user confirmation""",
111
129
  )
112
130
  sessions = SessionStore()
113
131
  backend = BackendClient()
114
132
  AuthTools(sessions, backend).register(server)
133
+ FeedbackTools(backend, mcp_side="通用").register(server)
115
134
  WorkspaceTools(sessions, backend).register(server)
116
135
  FileTools(sessions, backend).register(server)
117
136
  RecordTools(sessions, backend).register(server)
137
+ TaskContextTools(sessions, backend).register(server)
118
138
  RoleTools(sessions, backend).register(server)
119
139
  AppTools(sessions, backend).register(server)
120
140
  QingbiReportTools(sessions, backend).register(server)
@@ -7,6 +7,7 @@ from .config import DEFAULT_PROFILE
7
7
  from .session_store import SessionStore
8
8
  from .tools.ai_builder_tools import AiBuilderTools
9
9
  from .tools.auth_tools import AuthTools
10
+ from .tools.feedback_tools import FeedbackTools
10
11
  from .tools.file_tools import FileTools
11
12
  from .tools.workspace_tools import WorkspaceTools
12
13
 
@@ -26,7 +27,8 @@ def build_builder_server() -> FastMCP:
26
27
  "Use package_attach_app to attach apps to packages, and app_publish_verify for explicit final publish verification. "
27
28
  "For workflow edits, prefer preset plus explicit patching over generating a full custom graph from scratch, and declare node assignees and editable fields explicitly. "
28
29
  "If builder writes are blocked by the current user's own edit lock, use app_release_edit_lock_if_mine with the lock owner details from the failed result. "
29
- "Do not handcraft internal solution payloads or rely on build_id/stage/repair."
30
+ "Do not handcraft internal solution payloads or rely on build_id/stage/repair. "
31
+ "If the current MCP capability is unsupported, the workflow is awkward, or the user's need still cannot be satisfied after reasonable use, first summarize the gap, ask whether to submit feedback, and call feedback_submit only after explicit user confirmation."
30
32
  ),
31
33
  )
32
34
  sessions = SessionStore()
@@ -35,6 +37,7 @@ def build_builder_server() -> FastMCP:
35
37
  workspace = WorkspaceTools(sessions, backend)
36
38
  files = FileTools(sessions, backend)
37
39
  ai_builder = AiBuilderTools(sessions, backend)
40
+ feedback = FeedbackTools(backend, mcp_side="App Builder MCP")
38
41
 
39
42
  @server.tool()
40
43
  def auth_login(
@@ -120,6 +123,8 @@ def build_builder_server() -> FastMCP:
120
123
  file_related_url=file_related_url,
121
124
  )
122
125
 
126
+ feedback.register(server)
127
+
123
128
  @server.tool()
124
129
  def package_list(profile: str = DEFAULT_PROFILE, trial_status: str = "all") -> dict:
125
130
  return ai_builder.package_list(profile=profile, trial_status=trial_status)
@@ -10,8 +10,10 @@ from .session_store import SessionStore
10
10
  from .tools.app_tools import AppTools
11
11
  from .tools.auth_tools import AuthTools
12
12
  from .tools.directory_tools import DirectoryTools
13
+ from .tools.feedback_tools import FeedbackTools
13
14
  from .tools.file_tools import FileTools
14
15
  from .tools.record_tools import RecordTools
16
+ from .tools.task_context_tools import TaskContextTools
15
17
  from .tools.workspace_tools import WorkspaceTools
16
18
 
17
19
 
@@ -81,6 +83,14 @@ Analysis answers must include concrete numbers. When applicable, include percent
81
83
  - If a member or department field id is known but candidate ids are not, use `record_member_candidates` or `record_department_candidates` before `record_write`.
82
84
  - For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
83
85
 
86
+ ## Task Workflow Path
87
+
88
+ `task_list -> task_get -> task_action_execute`
89
+
90
+ - Use `task_associated_report_detail_get` for associated view or report details.
91
+ - Use `task_workflow_log_get` for full workflow log history.
92
+ - Task actions operate on `app_key + record_id + workflow_node_id`, not `task_id`.
93
+
84
94
  ## Time Handling
85
95
 
86
96
  Normalize relative dates before building DSL.
@@ -95,7 +105,15 @@ Default to `prod` unless the user explicitly specifies `test`.
95
105
 
96
106
  ## Constraints
97
107
 
98
- Avoid builder-side app or schema changes here.""",
108
+ Avoid builder-side app or schema changes here.
109
+
110
+ ## Feedback Path
111
+
112
+ If the current MCP capability is unsupported, the workflow is awkward, or the user's need still cannot be satisfied after reasonable use, offer to submit product feedback.
113
+
114
+ - First summarize what is still not working
115
+ - Ask the user whether to submit feedback
116
+ - Call `feedback_submit` only after explicit user confirmation""",
99
117
  )
100
118
  sessions = SessionStore()
101
119
  backend = BackendClient()
@@ -103,6 +121,7 @@ Avoid builder-side app or schema changes here.""",
103
121
  apps = AppTools(sessions, backend)
104
122
  workspace = WorkspaceTools(sessions, backend)
105
123
  files = FileTools(sessions, backend)
124
+ feedback = FeedbackTools(backend, mcp_side="App User MCP")
106
125
 
107
126
  @server.tool()
108
127
  def auth_login(
@@ -218,9 +237,11 @@ Avoid builder-side app or schema changes here.""",
218
237
  bucket_type=bucket_type,
219
238
  path_id=path_id,
220
239
  file_related_url=file_related_url,
221
- )
240
+ )
222
241
 
242
+ feedback.register(server)
223
243
  RecordTools(sessions, backend).register(server)
244
+ TaskContextTools(sessions, backend).register(server)
224
245
  DirectoryTools(sessions, backend).register(server)
225
246
 
226
247
  return server
@@ -611,9 +611,11 @@ class ApprovalTools(ToolBase):
611
611
  self._normalize_alias(body, "formId", "form_id")
612
612
 
613
613
  node_id = self._extract_node_id(body)
614
- body["nodeId"] = node_id
614
+ body["nodeId"] = self._resolve_actionable_node_id(context, app_key, apply_id, node_id)
615
615
  body["applyId"] = self._match_or_fill_int(body, field_name="applyId", expected_value=apply_id)
616
616
  body["formId"] = self._resolve_form_id(profile, context, app_key, explicit_form_id=body.get("formId"))
617
+ if body.get("answers") is None:
618
+ body["answers"] = self._fetch_current_todo_answers(context, app_key, apply_id, body["nodeId"])
617
619
 
618
620
  self._validate_approval_payload(body)
619
621
  return body
@@ -672,6 +674,54 @@ class ApprovalTools(ToolBase):
672
674
  elif alias_value is not None and payload.get(canonical_key) != alias_value:
673
675
  raise_tool_error(QingflowApiError.config_error(f"payload.{canonical_key} and payload.{alias_key} must match when both are provided"))
674
676
 
677
+ def _resolve_actionable_node_id(self, context, app_key: str, apply_id: int, node_id: int) -> int: # type: ignore[no-untyped-def]
678
+ infos = self.backend.request(
679
+ "GET",
680
+ context,
681
+ f"/app/{app_key}/apply/{apply_id}/auditInfo",
682
+ params={"type": 1},
683
+ )
684
+ if not isinstance(infos, list) or not infos:
685
+ raise_tool_error(
686
+ QingflowApiError.config_error(
687
+ f"apply_id={apply_id} is not currently actionable for the logged-in user in todo list"
688
+ )
689
+ )
690
+ actionable_node_ids = {
691
+ candidate
692
+ for item in infos
693
+ if isinstance(item, dict)
694
+ for candidate in (item.get("auditNodeId"), item.get("nodeId"))
695
+ if isinstance(candidate, int) and candidate > 0
696
+ }
697
+ if node_id not in actionable_node_ids:
698
+ raise_tool_error(
699
+ QingflowApiError.config_error(
700
+ f"payload.nodeId={node_id} is not an actionable todo node for apply_id={apply_id}"
701
+ )
702
+ )
703
+ return node_id
704
+
705
+ def _fetch_current_todo_answers(self, context, app_key: str, apply_id: int, node_id: int) -> list[dict[str, Any]]: # type: ignore[no-untyped-def]
706
+ detail = self.backend.request(
707
+ "GET",
708
+ context,
709
+ f"/app/{app_key}/apply/{apply_id}",
710
+ params={"role": 3, "listType": 1, "auditNodeId": node_id},
711
+ )
712
+ answers = detail.get("answers") if isinstance(detail, dict) else None
713
+ if not isinstance(answers, list):
714
+ raise_tool_error(
715
+ QingflowApiError.config_error(
716
+ f"cannot resolve current answers for apply_id={apply_id} nodeId={node_id}"
717
+ )
718
+ )
719
+ normalized_answers: list[dict[str, Any]] = []
720
+ for item in answers:
721
+ if isinstance(item, dict):
722
+ normalized_answers.append(dict(item))
723
+ return normalized_answers
724
+
675
725
  def _validate_approval_payload(self, payload: dict[str, Any]) -> None:
676
726
  self._reject_unsupported_fields(payload)
677
727
  if not isinstance(payload.get("formId"), int) or payload["formId"] <= 0:
@@ -680,6 +730,9 @@ class ApprovalTools(ToolBase):
680
730
  raise_tool_error(QingflowApiError.config_error("payload.applyId must be a positive integer"))
681
731
  if not isinstance(payload.get("nodeId"), int) or payload["nodeId"] <= 0:
682
732
  raise_tool_error(QingflowApiError.config_error("payload.nodeId must be a positive integer"))
733
+ answers = payload.get("answers")
734
+ if answers is not None and not isinstance(answers, list):
735
+ raise_tool_error(QingflowApiError.config_error("payload.answers must be an array when provided"))
683
736
 
684
737
  def _validate_audit_payload(self, payload: dict[str, Any], *, require_uid: bool = False) -> None:
685
738
  self._reject_unsupported_fields(payload)
@@ -0,0 +1,224 @@
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
+ return self.feedback_submit(
50
+ category=category,
51
+ title=title,
52
+ description=description,
53
+ expected_behavior=expected_behavior,
54
+ actual_behavior=actual_behavior,
55
+ impact_scope=impact_scope,
56
+ tool_name=tool_name,
57
+ app_key=app_key,
58
+ record_id=record_id,
59
+ workflow_node_id=workflow_node_id,
60
+ note=note,
61
+ )
62
+
63
+ def feedback_submit(
64
+ self,
65
+ *,
66
+ category: str,
67
+ title: str,
68
+ description: str,
69
+ expected_behavior: str | None,
70
+ actual_behavior: str | None,
71
+ impact_scope: str | None,
72
+ tool_name: str | None,
73
+ app_key: str | None,
74
+ record_id: str | int | None,
75
+ workflow_node_id: str | int | None,
76
+ note: str | None,
77
+ ) -> JSONObject:
78
+ qsource_token = get_feedback_qsource_token()
79
+ if not qsource_token:
80
+ raise_tool_error(
81
+ QingflowApiError(
82
+ category="config",
83
+ message=(
84
+ "feedback_submit is not configured. Set "
85
+ "feedback.qsource_token or QINGFLOW_MCP_FEEDBACK_QSOURCE_TOKEN first."
86
+ ),
87
+ details={"error_code": "FEEDBACK_NOT_CONFIGURED"},
88
+ )
89
+ )
90
+
91
+ base_url = get_feedback_base_url()
92
+ if not base_url:
93
+ raise_tool_error(
94
+ QingflowApiError.config_error(
95
+ "feedback_submit requires a base_url. Configure feedback.base_url or default_base_url."
96
+ )
97
+ )
98
+
99
+ normalized_payload = self._build_payload(
100
+ category=category,
101
+ title=title,
102
+ description=description,
103
+ expected_behavior=expected_behavior,
104
+ actual_behavior=actual_behavior,
105
+ impact_scope=impact_scope,
106
+ tool_name=tool_name,
107
+ app_key=app_key,
108
+ record_id=record_id,
109
+ workflow_node_id=workflow_node_id,
110
+ note=note,
111
+ )
112
+
113
+ try:
114
+ response = self.backend.public_request_with_meta(
115
+ "POST",
116
+ base_url,
117
+ f"/qsource/{qsource_token}",
118
+ json_body=normalized_payload,
119
+ unwrap=True,
120
+ qf_version=None,
121
+ )
122
+ except QingflowApiError as exc:
123
+ raise_tool_error(exc)
124
+ result = response.data if isinstance(response.data, dict) else {}
125
+ feedback_request_id = result.get("requestId") if isinstance(result, dict) else None
126
+
127
+ return {
128
+ "ok": True,
129
+ "request_route": {
130
+ "base_url": normalize_base_url(base_url) or base_url,
131
+ "qf_version": None,
132
+ "qf_version_source": "not_applicable",
133
+ },
134
+ "submission_mode": "qsource_passive",
135
+ "feedback_target": {
136
+ "app_key": get_feedback_app_key(),
137
+ "mcp_side": self.mcp_side,
138
+ },
139
+ "normalized_payload": normalized_payload,
140
+ "feedback_request_id": feedback_request_id,
141
+ }
142
+
143
+ def _build_payload(
144
+ self,
145
+ *,
146
+ category: str,
147
+ title: str,
148
+ description: str,
149
+ expected_behavior: str | None,
150
+ actual_behavior: str | None,
151
+ impact_scope: str | None,
152
+ tool_name: str | None,
153
+ app_key: str | None,
154
+ record_id: str | int | None,
155
+ workflow_node_id: str | int | None,
156
+ note: str | None,
157
+ ) -> JSONObject:
158
+ payload: JSONObject = {
159
+ "title": self._require_text("title", title),
160
+ "category": self._normalize_label("category", category, CATEGORY_MAP, required=True),
161
+ "description": self._require_text("description", description),
162
+ "submit_method": "AI代提",
163
+ "status": "待处理",
164
+ "mcp_side": self.mcp_side,
165
+ }
166
+
167
+ optional_text = {
168
+ "expected_result": expected_behavior,
169
+ "actual_behavior": actual_behavior,
170
+ "tool_name": tool_name,
171
+ "app_key": app_key,
172
+ "note": note,
173
+ }
174
+ for key, value in optional_text.items():
175
+ normalized = self._normalize_optional_text(value)
176
+ if normalized is not None:
177
+ payload[key] = normalized
178
+
179
+ if impact_scope is not None and str(impact_scope).strip():
180
+ payload["impact_scope"] = self._normalize_label("impact_scope", impact_scope, IMPACT_SCOPE_MAP, required=False)
181
+
182
+ if record_id is not None and str(record_id).strip():
183
+ payload["record_id"] = str(record_id).strip()
184
+ if workflow_node_id is not None and str(workflow_node_id).strip():
185
+ payload["workflow_node_id"] = str(workflow_node_id).strip()
186
+
187
+ return payload
188
+
189
+ def _normalize_label(self, field: str, value: str, mapping: dict[str, str], *, required: bool) -> str:
190
+ text = str(value or "").strip()
191
+ if not text:
192
+ if required:
193
+ raise_tool_error(QingflowApiError.config_error(f"{field} is required"))
194
+ return ""
195
+
196
+ canonical = text.lower()
197
+ if canonical in mapping:
198
+ return mapping[canonical]
199
+ if text in mapping.values():
200
+ return text
201
+ supported_values = list(mapping.keys()) + list(mapping.values())
202
+ raise_tool_error(
203
+ QingflowApiError(
204
+ category="config",
205
+ message=f"{field} must be one of the supported canonical values or labels",
206
+ details={
207
+ "error_code": "FEEDBACK_INVALID_INPUT",
208
+ "field": field,
209
+ "supported_values": supported_values,
210
+ },
211
+ )
212
+ )
213
+
214
+ def _require_text(self, field: str, value: str) -> str:
215
+ normalized = str(value or "").strip()
216
+ if not normalized:
217
+ raise_tool_error(QingflowApiError.config_error(f"{field} is required"))
218
+ return normalized
219
+
220
+ def _normalize_optional_text(self, value: str | None) -> str | None:
221
+ if value is None:
222
+ return None
223
+ normalized = str(value).strip()
224
+ return normalized or None
@@ -3367,7 +3367,10 @@ class RecordTools(ToolBase):
3367
3367
  for item in match_rules:
3368
3368
  if not isinstance(item, dict):
3369
3369
  continue
3370
- queries.extend(_normalize_list_query_rules(item))
3370
+ item_queries = _normalize_list_query_rules(item)
3371
+ if item_queries:
3372
+ queries.extend(item_queries)
3373
+ continue
3371
3374
  normalized_match_rules.extend(_normalize_list_match_rules(item))
3372
3375
  if queries:
3373
3376
  body["queries"] = queries