@josephyan/qingflow-app-user-mcp 0.2.0-beta.47 → 0.2.0-beta.48

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-user-mcp@0.2.0-beta.47
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.48
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.47 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.48 qingflow-app-user-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-user-mcp",
3
- "version": "0.2.0-beta.47",
3
+ "version": "0.2.0-beta.48",
4
4
  "description": "Operational end-user MCP for Qingflow records, tasks, comments, and directory 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.0b47"
7
+ version = "0.2.0b48"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -32,7 +32,7 @@ Route to exactly one of these specialized paths:
32
32
 
33
33
  - If the user does not know the target `app_key`, discover apps first with `app_list` or `app_search`, then route to the specialized skill
34
34
  - If the app is known but the available data range is unclear, call `app_get` first and inspect `accessible_views`
35
- - If the task is about browsing, reading, creating, updating, deleting, attachments, relations, subtable writes, member/department-field candidate lookup, import templates, import capability discovery, import-file verification, authorized local file repair, import execution, or import status, switch to `$qingflow-record-crud`
35
+ - If the task is about browsing, reading, creating, updating, deleting, attachments, relations, subtable writes, member/department-field candidate lookup, code-block field execution, import templates, import capability discovery, import-file verification, authorized local file repair, import execution, or import status, switch to `$qingflow-record-crud`
36
36
  - If the task is about todo discovery, task context, approval actions, rollback or transfer, associated report review, or workflow log review, switch to `$qingflow-task-ops`
37
37
  - If the task is about grouped distributions, ratios, rankings, trends, insights, or any final statistical conclusion, switch to `$qingflow-record-analysis`
38
38
  - If the MCP is not connected, authenticated, or bound to the right workspace, switch to `$qingflow-mcp-setup`
@@ -19,7 +19,8 @@ Use exactly one of these default paths:
19
19
  1. Browse records: `app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_list`
20
20
  2. Read one record: `app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_get`
21
21
  3. Write records: `record_schema_get(schema_mode="applicant") -> record_write`
22
- 4. Import records: `record_import_template_get -> record_import_verify -> (optional authorized file repair) -> record_import_start -> record_import_status_get`
22
+ 4. Run a code-block field: `record_schema_get(schema_mode="applicant") -> record_code_block_run`
23
+ 5. Import records: `record_import_template_get -> record_import_verify -> (optional authorized file repair) -> record_import_start -> record_import_status_get`
23
24
 
24
25
  ## Core Tools
25
26
 
@@ -27,6 +28,7 @@ Use exactly one of these default paths:
27
28
  - `record_list`
28
29
  - `record_get`
29
30
  - `record_write`
31
+ - `record_code_block_run`
30
32
  - `record_import_template_get`
31
33
  - `record_import_verify`
32
34
  - `record_import_repair_local`
@@ -63,11 +65,12 @@ Use `record_member_candidates` / `record_department_candidates` as the default l
63
65
  6. Run `record_schema_get(schema_mode="browse", view_id=...)` before browse/read
64
66
  7. Run `record_schema_get(schema_mode="applicant")` before write
65
67
  8. If the request is import-like, decide whether the user needs template download, file verification, file repair, import execution, or import status before changing any file
66
- 9. If the request is analysis-like, switch to [$qingflow-record-analysis](/Users/yanqidong/Documents/qingflow-next/.codex/skills/qingflow-record-analysis/SKILL.md)
67
- 10. If the request is write-like, decide `insert / update / delete` before building any payload
68
- 11. If fields are still ambiguous after `record_schema_get`, ask the user to confirm from a short candidate list instead of guessing
69
- 12. For high-risk writes or production changes, read the current state first whenever practical
70
- 13. After actions, report the affected `record_id`, counts, or returned item count
68
+ 9. If the request is code-block-like, inspect applicant schema first and confirm the exact `code_block_field`
69
+ 10. If the request is analysis-like, switch to [$qingflow-record-analysis](/Users/yanqidong/Documents/qingflow-next/.codex/skills/qingflow-record-analysis/SKILL.md)
70
+ 11. If the request is write-like, decide `insert / update / delete` before building any payload
71
+ 12. If fields are still ambiguous after `record_schema_get`, ask the user to confirm from a short candidate list instead of guessing
72
+ 13. For high-risk writes or production changes, read the current state first whenever practical
73
+ 14. After actions, report the affected `record_id`, counts, or returned item count
71
74
 
72
75
  ## Record Read Rules
73
76
 
@@ -131,6 +134,35 @@ The DSL is clause-shaped like SQL, but it is **not raw SQL text**.
131
134
  - `relation`: `✅ {"field_id":25,"value":{"apply_id":5001}}` / `❌ {"field_id":25,"value":"客户A"}`
132
135
  - `attachment`: upload first, then `✅ {"field_id":13,"value":{"value":"https://.../a.pdf","name":"a.pdf"}}` / `❌ {"field_id":13,"value":"/tmp/a.pdf"}`
133
136
 
137
+ ## Code Block Rules
138
+
139
+ Use `record_code_block_run` when the user wants to run a form code-block field against an existing record and optionally let Qingflow auto-write bound relation outputs back into the same record.
140
+
141
+ ### Code block workflow
142
+
143
+ 1. Run `record_schema_get(schema_mode="applicant")`
144
+ 2. Select the exact code-block field from schema
145
+ 3. Confirm the target `record_id`
146
+ 4. Choose the right execution context:
147
+ - default record context: `role=1`
148
+ - workflow task context: pass `role=3` and `workflow_node_id`
149
+ 5. Run `record_code_block_run`
150
+ 6. Check:
151
+ - `outputs.alias_results`
152
+ - `relation.target_fields`
153
+ - `writeback.applied`
154
+ - `writeback.verification`
155
+
156
+ ### Code block discipline
157
+
158
+ - `code_block_field` must resolve to a real code-block field; do not guess by approximate title
159
+ - Prefer the current stored record answers as inputs
160
+ - Only pass temporary `answers` or `fields` overrides when the user explicitly wants a what-if execution or the current record data is known to be incomplete
161
+ - Treat this tool as a write-capable operation, not a pure read
162
+ - If the code block is bound to relation outputs, MCP will trigger Qingflow's relation-calculation chain and may auto-write calculated answers back to the record
163
+ - If `verify_writeback=true`, read `writeback.write_verified` and `writeback.verification` before claiming success
164
+ - In workflow context, `workflow_node_id` is required when `role=3`
165
+
134
166
  ## Import Rules
135
167
 
136
168
  Use the import tools for file-based bulk data loading, not `record_write`.
@@ -2,4 +2,4 @@ from __future__ import annotations
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0b47"
5
+ __version__ = "0.2.0b48"
@@ -1250,7 +1250,7 @@ class AiBuilderFacade:
1250
1250
  if app_result.get("status") != "success":
1251
1251
  return app_result
1252
1252
  resolved_app_key = str(app_result.get("app_key") or app_key)
1253
- items = self.charts.qingbi_report_list(profile=profile, app_key=resolved_app_key).get("items") or []
1253
+ items, list_source = self._load_chart_list_for_builder(profile=profile, app_key=resolved_app_key)
1254
1254
  except (QingflowApiError, RuntimeError) as error:
1255
1255
  api_error = _coerce_api_error(error)
1256
1256
  return _failed_from_api_error(
@@ -1278,12 +1278,22 @@ class AiBuilderFacade:
1278
1278
  "request_id": None,
1279
1279
  "suggested_next_call": None,
1280
1280
  "noop": False,
1281
- "warnings": [],
1282
- "verification": {"app_exists": True},
1281
+ "warnings": [] if list_source == "sorted" else [_warning("CHART_ORDER_UNVERIFIED", "chart summary order uses fallback listing and may not reflect saved chart sort order")],
1282
+ "verification": {"app_exists": True, "chart_order_verified": list_source == "sorted", "chart_list_source": list_source},
1283
1283
  "verified": True,
1284
1284
  **response.model_dump(mode="json"),
1285
1285
  }
1286
1286
 
1287
+ def _load_chart_list_for_builder(self, *, profile: str, app_key: str) -> tuple[list[dict[str, Any]], str]:
1288
+ try:
1289
+ sorted_items = self.charts.qingbi_report_list_sorted(profile=profile, app_key=app_key, page_num=1, page_size=500).get("items") or []
1290
+ if isinstance(sorted_items, list):
1291
+ return sorted_items, "sorted"
1292
+ except (QingflowApiError, RuntimeError):
1293
+ pass
1294
+ fallback_items = self.charts.qingbi_report_list(profile=profile, app_key=app_key).get("items") or []
1295
+ return list(fallback_items) if isinstance(fallback_items, list) else [], "fallback"
1296
+
1287
1297
  def portal_read_summary(self, *, profile: str, dash_key: str, being_draft: bool = True) -> JSONObject:
1288
1298
  try:
1289
1299
  result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=being_draft).get("result") or {}
@@ -3231,7 +3241,7 @@ class AiBuilderFacade:
3231
3241
  parsed = schema_state.get("parsed") if isinstance(schema_state.get("parsed"), dict) else {}
3232
3242
  fields = parsed.get("fields") if isinstance(parsed.get("fields"), list) else []
3233
3243
  qingbi_fields = self.charts.qingbi_report_list_fields(profile=profile, app_key=app_key).get("items") or []
3234
- existing_chart_items = self.charts.qingbi_report_list(profile=profile, app_key=app_key).get("items") or []
3244
+ existing_chart_items, existing_chart_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
3235
3245
  except (QingflowApiError, RuntimeError) as error:
3236
3246
  api_error = _coerce_api_error(error)
3237
3247
  return _failed_from_api_error(
@@ -3305,7 +3315,7 @@ class AiBuilderFacade:
3305
3315
  create_result = self.charts.qingbi_report_create(profile=profile, payload=create_payload).get("result") or {}
3306
3316
  created_chart_id = _extract_chart_identifier(create_result or {})
3307
3317
  if not created_chart_id:
3308
- refreshed_items = self.charts.qingbi_report_list(profile=profile, app_key=app_key).get("items") or []
3318
+ refreshed_items, _ = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
3309
3319
  refreshed_matches = _find_charts_by_name(
3310
3320
  refreshed_items,
3311
3321
  chart_name=patch.name,
@@ -3331,10 +3341,23 @@ class AiBuilderFacade:
3331
3341
  existing_by_id[chart_id] = deepcopy(created_chart)
3332
3342
  existing_by_name.setdefault(patch.name, []).append(deepcopy(created_chart))
3333
3343
  elif existing_name != patch.name or existing_type != target_type:
3344
+ base_info = self.charts.qingbi_report_get_base(profile=profile, chart_id=chart_id).get("result") or {}
3334
3345
  self.charts.qingbi_report_update_base(
3335
3346
  profile=profile,
3336
3347
  chart_id=chart_id,
3337
- payload={"chartName": patch.name, "chartType": target_type},
3348
+ payload={
3349
+ "chartId": chart_id,
3350
+ "chartName": patch.name,
3351
+ "chartType": target_type,
3352
+ "dataSourceType": base_info.get("dataSourceType") or (existing or {}).get("dataSourceType") or "qingflow",
3353
+ "tagId": "0",
3354
+ "parentId": "0",
3355
+ "dataSourceId": base_info.get("dataSourceId") or app_key,
3356
+ "visibleAuth": deepcopy(base_info.get("visibleAuth") or qingbi_workspace_visible_auth()),
3357
+ "editAuthList": deepcopy(base_info.get("editAuthList") or []),
3358
+ "editAuthType": base_info.get("editAuthType") or "ws",
3359
+ "editAuthIncludeSubDept": bool(base_info.get("editAuthIncludeSubDept", True)),
3360
+ },
3338
3361
  )
3339
3362
  updated_ids.append(chart_id)
3340
3363
  updated_chart = deepcopy(existing or {})
@@ -3417,7 +3440,14 @@ class AiBuilderFacade:
3417
3440
  reordered = False
3418
3441
  if request.reorder_chart_ids:
3419
3442
  try:
3420
- self.charts.qingbi_report_reorder(profile=profile, app_key=app_key, chart_ids=request.reorder_chart_ids)
3443
+ current_order = [
3444
+ _extract_chart_identifier(item)
3445
+ for item in existing_chart_items
3446
+ if isinstance(item, dict) and _extract_chart_identifier(item)
3447
+ ]
3448
+ desired_display_order = list(request.reorder_chart_ids) + [chart_id for chart_id in current_order if chart_id not in request.reorder_chart_ids]
3449
+ backend_reorder_ids = list(reversed(desired_display_order))
3450
+ self.charts.qingbi_report_reorder(profile=profile, app_key=app_key, chart_ids=backend_reorder_ids)
3421
3451
  reordered = True
3422
3452
  except (QingflowApiError, RuntimeError) as error:
3423
3453
  api_error = _coerce_api_error(error)
@@ -3434,7 +3464,7 @@ class AiBuilderFacade:
3434
3464
 
3435
3465
  noop = not created_ids and not updated_ids and not removed_ids and not reordered and not failed_items
3436
3466
  try:
3437
- readback_items = self.charts.qingbi_report_list(profile=profile, app_key=app_key).get("items") or []
3467
+ readback_items, readback_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
3438
3468
  readback_ids = {
3439
3469
  _extract_chart_identifier(item)
3440
3470
  for item in readback_items
@@ -3451,11 +3481,12 @@ class AiBuilderFacade:
3451
3481
  if isinstance(item, dict) and _extract_chart_identifier(item)
3452
3482
  ]
3453
3483
  requested_existing = [chart_id for chart_id in request.reorder_chart_ids if chart_id in ordered_readback]
3454
- verified = verified and ordered_readback[: len(requested_existing)] == requested_existing
3484
+ verified = verified and readback_list_source == "sorted" and ordered_readback[: len(requested_existing)] == requested_existing
3455
3485
  readback_unavailable = False
3456
3486
  except (QingflowApiError, RuntimeError):
3457
3487
  verified = False
3458
3488
  readback_unavailable = True
3489
+ readback_list_source = None
3459
3490
 
3460
3491
  if failed_items:
3461
3492
  successful_changes = bool(created_ids or updated_ids or removed_ids or reordered)
@@ -3478,7 +3509,12 @@ class AiBuilderFacade:
3478
3509
  readback_unavailable=readback_unavailable,
3479
3510
  verified=False if failed_items else verified,
3480
3511
  ),
3481
- "verification": {"charts_verified": False if failed_items else verified, "readback_unavailable": readback_unavailable},
3512
+ "verification": {
3513
+ "charts_verified": False if failed_items else verified,
3514
+ "readback_unavailable": readback_unavailable,
3515
+ "chart_order_verified": False if request.reorder_chart_ids else (readback_list_source == "sorted"),
3516
+ "chart_list_source": readback_list_source or existing_chart_list_source,
3517
+ },
3482
3518
  "app_key": app_key,
3483
3519
  "chart_results": chart_results,
3484
3520
  "verified": False if failed_items else verified,
@@ -3501,7 +3537,12 @@ class AiBuilderFacade:
3501
3537
  readback_unavailable=False if noop else readback_unavailable,
3502
3538
  verified=result_verified,
3503
3539
  ),
3504
- "verification": {"charts_verified": result_verified, "readback_unavailable": False if noop else readback_unavailable},
3540
+ "verification": {
3541
+ "charts_verified": result_verified,
3542
+ "readback_unavailable": False if noop else readback_unavailable,
3543
+ "chart_order_verified": True if noop and not request.reorder_chart_ids else (readback_list_source == "sorted" and result_verified if request.reorder_chart_ids else readback_list_source == "sorted"),
3544
+ "chart_list_source": existing_chart_list_source if noop else readback_list_source,
3545
+ },
3505
3546
  "app_key": app_key,
3506
3547
  "chart_results": chart_results,
3507
3548
  "verified": result_verified,
@@ -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.code_block_tools import CodeBlockTools
11
12
  from .tools.feedback_tools import FeedbackTools
12
13
  from .tools.file_tools import FileTools
13
14
  from .tools.import_tools import ImportTools
@@ -16,7 +17,6 @@ from .tools.navigation_tools import NavigationTools
16
17
  from .tools.directory_tools import DirectoryTools
17
18
  from .tools.portal_tools import PortalTools
18
19
  from .tools.qingbi_report_tools import QingbiReportTools
19
- from .tools.record_tools import RecordTools
20
20
  from .tools.role_tools import RoleTools
21
21
  from .tools.solution_tools import SolutionTools
22
22
  from .tools.task_context_tools import TaskContextTools
@@ -95,6 +95,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
95
95
 
96
96
  `app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_list / record_get`
97
97
  `record_schema_get(schema_mode="applicant") -> record_write`
98
+ `record_schema_get(schema_mode="applicant") -> record_code_block_run`
98
99
 
99
100
  - Use `columns` as `[{{field_id}}]`
100
101
  - Use `where` items as `{{field_id, op, value}}`
@@ -111,6 +112,16 @@ Analysis answers must include concrete numbers. When applicable, include percent
111
112
  - 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`.
112
113
  - For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
113
114
 
115
+ ## Code Block Path
116
+
117
+ Use `record_code_block_run` when the user wants to execute a form code-block field against an existing record.
118
+
119
+ - Always resolve the exact code-block field from `record_schema_get(schema_mode="applicant")` first.
120
+ - Treat code-block execution as write-capable, not read-only.
121
+ - If the code block is bound to relation outputs, Qingflow may calculate target answers and write them back automatically.
122
+ - In workflow context, pass `role=3` and the exact `workflow_node_id`.
123
+ - After execution, inspect `outputs.alias_results`, `relation.target_fields`, and `writeback.verification` before claiming success.
124
+
114
125
  ## Import Path
115
126
 
116
127
  `app_get -> record_import_template_get -> record_import_verify -> (optional authorized record_import_repair_local) -> record_import_start -> record_import_status_get`
@@ -161,7 +172,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
161
172
  WorkspaceTools(sessions, backend).register(server)
162
173
  FileTools(sessions, backend).register(server)
163
174
  ImportTools(sessions, backend).register(server)
164
- RecordTools(sessions, backend).register(server)
175
+ CodeBlockTools(sessions, backend).register(server)
165
176
  TaskContextTools(sessions, backend).register(server)
166
177
  RoleTools(sessions, backend).register(server)
167
178
  AppTools(sessions, backend).register(server)
@@ -9,11 +9,11 @@ from .config import DEFAULT_PROFILE
9
9
  from .session_store import SessionStore
10
10
  from .tools.app_tools import AppTools
11
11
  from .tools.auth_tools import AuthTools
12
+ from .tools.code_block_tools import CodeBlockTools
12
13
  from .tools.directory_tools import DirectoryTools
13
14
  from .tools.feedback_tools import FeedbackTools
14
15
  from .tools.file_tools import FileTools
15
16
  from .tools.import_tools import ImportTools
16
- from .tools.record_tools import RecordTools
17
17
  from .tools.task_context_tools import TaskContextTools
18
18
  from .tools.workspace_tools import WorkspaceTools
19
19
 
@@ -83,6 +83,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
83
83
 
84
84
  `app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_list / record_get`
85
85
  `record_schema_get(schema_mode="applicant") -> record_write`
86
+ `record_schema_get(schema_mode="applicant") -> record_code_block_run`
86
87
 
87
88
  - Use `columns` as `[{{field_id}}]`
88
89
  - Use `where` items as `{{field_id, op, value}}`
@@ -99,6 +100,16 @@ Analysis answers must include concrete numbers. When applicable, include percent
99
100
  - 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`.
100
101
  - For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
101
102
 
103
+ ## Code Block Path
104
+
105
+ Use `record_code_block_run` when the user wants to execute a form code-block field against an existing record.
106
+
107
+ - Always resolve the exact code-block field from `record_schema_get(schema_mode="applicant")` first.
108
+ - Treat code-block execution as write-capable, not read-only.
109
+ - If the code block is bound to relation outputs, Qingflow may calculate target answers and write them back automatically.
110
+ - In workflow context, pass `role=3` and the exact `workflow_node_id`.
111
+ - After execution, inspect `outputs.alias_results`, `relation.target_fields`, and `writeback.verification` before claiming success.
112
+
102
113
  ## Import Path
103
114
 
104
115
  `app_get -> record_import_template_get -> record_import_verify -> (optional authorized record_import_repair_local) -> record_import_start -> record_import_status_get`
@@ -274,7 +285,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
274
285
  imports.register(server)
275
286
 
276
287
  feedback.register(server)
277
- RecordTools(sessions, backend).register(server)
288
+ CodeBlockTools(sessions, backend).register(server)
278
289
  TaskContextTools(sessions, backend).register(server)
279
290
  DirectoryTools(sessions, backend).register(server)
280
291
 
@@ -0,0 +1,584 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import cast
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE
8
+ from ..errors import QingflowApiError, raise_tool_error
9
+ from ..json_types import JSONObject, JSONValue
10
+ from .record_tools import (
11
+ ATTACHMENT_QUE_TYPES,
12
+ DEPARTMENT_QUE_TYPES,
13
+ MEMBER_QUE_TYPES,
14
+ MULTI_SELECT_QUE_TYPES,
15
+ RELATION_QUE_TYPES,
16
+ SINGLE_SELECT_QUE_TYPES,
17
+ SUBTABLE_QUE_TYPES,
18
+ FieldIndex,
19
+ FormField,
20
+ RecordTools,
21
+ _coerce_count,
22
+ _collect_question_relations,
23
+ _field_ref_payload,
24
+ _normalize_optional_text,
25
+ _relation_ids_from_answer,
26
+ _stringify_json,
27
+ )
28
+
29
+
30
+ CODE_BLOCK_QUE_TYPE = 26
31
+ CODE_BLOCK_RELATION_TYPE = 3
32
+ SUPPORTED_CODE_BLOCK_ROLES = {1, 2, 3, 5}
33
+
34
+
35
+ class CodeBlockTools(RecordTools):
36
+ def register(self, mcp: FastMCP) -> None:
37
+ super().register(mcp)
38
+
39
+ @mcp.tool(
40
+ description=(
41
+ "Run a form code-block field against the current record data, then reuse Qingflow's existing "
42
+ "relation-calculation chain to compute bound outputs and write them back automatically. "
43
+ "Use an exact code-block field selector from the form schema."
44
+ )
45
+ )
46
+ def record_code_block_run(
47
+ profile: str = DEFAULT_PROFILE,
48
+ app_key: str = "",
49
+ record_id: int = 0,
50
+ code_block_field: str = "",
51
+ role: int = 1,
52
+ workflow_node_id: int | None = None,
53
+ answers: list[JSONObject] | None = None,
54
+ fields: JSONObject | None = None,
55
+ manual: bool = True,
56
+ verify_writeback: bool = True,
57
+ force_refresh_form: bool = False,
58
+ output_profile: str = "normal",
59
+ ) -> JSONObject:
60
+ return self.record_code_block_run(
61
+ profile=profile,
62
+ app_key=app_key,
63
+ record_id=record_id,
64
+ code_block_field=code_block_field,
65
+ role=role,
66
+ workflow_node_id=workflow_node_id,
67
+ answers=answers or [],
68
+ fields=fields or {},
69
+ manual=manual,
70
+ verify_writeback=verify_writeback,
71
+ force_refresh_form=force_refresh_form,
72
+ output_profile=output_profile,
73
+ )
74
+
75
+ def record_code_block_run(
76
+ self,
77
+ *,
78
+ profile: str,
79
+ app_key: str,
80
+ record_id: int,
81
+ code_block_field: str,
82
+ role: int = 1,
83
+ workflow_node_id: int | None = None,
84
+ answers: list[JSONObject] | None = None,
85
+ fields: JSONObject | None = None,
86
+ manual: bool = True,
87
+ verify_writeback: bool = True,
88
+ force_refresh_form: bool = False,
89
+ output_profile: str = "normal",
90
+ ) -> JSONObject:
91
+ normalized_record_id = self._validate_app_and_record(app_key, record_id)
92
+ normalized_output_profile = self._normalize_public_output_profile(output_profile)
93
+ if role not in SUPPORTED_CODE_BLOCK_ROLES:
94
+ raise_tool_error(QingflowApiError.config_error("role must be one of 1, 2, 3, or 5"))
95
+ if role == 3 and (workflow_node_id is None or workflow_node_id <= 0):
96
+ raise_tool_error(QingflowApiError.config_error("workflow_node_id is required when role=3"))
97
+ if not code_block_field:
98
+ raise_tool_error(QingflowApiError.config_error("code_block_field is required"))
99
+
100
+ def runner(session_profile, context):
101
+ schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
102
+ index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form)
103
+ code_block = self._resolve_field_selector(code_block_field, index, location="code_block_field")
104
+ if code_block.que_type != CODE_BLOCK_QUE_TYPE:
105
+ raise_tool_error(
106
+ QingflowApiError(
107
+ category="config",
108
+ message=f"field '{code_block.que_title}' is not a code-block field",
109
+ backend_code="CODE_BLOCK_FIELD_REQUIRED",
110
+ details={
111
+ "error_code": "CODE_BLOCK_FIELD_REQUIRED",
112
+ "field": _field_ref_payload(code_block),
113
+ "expected_que_type": CODE_BLOCK_QUE_TYPE,
114
+ },
115
+ )
116
+ )
117
+
118
+ current_answers = self._load_record_answers_for_code_block(
119
+ context,
120
+ app_key=app_key,
121
+ apply_id=normalized_record_id,
122
+ role=role,
123
+ audit_node_id=workflow_node_id,
124
+ )
125
+ override_answers = (
126
+ self._resolve_answers(
127
+ profile,
128
+ context,
129
+ app_key,
130
+ answers=answers or [],
131
+ fields=fields or {},
132
+ force_refresh_form=force_refresh_form,
133
+ )
134
+ if answers or fields
135
+ else []
136
+ )
137
+ merged_answers = self._merge_record_answers(current_answers, override_answers) if override_answers else current_answers
138
+ key_que_values = self._answers_to_open_match_values(merged_answers, index)
139
+ run_body: JSONObject = {
140
+ "role": role,
141
+ "manual": bool(manual),
142
+ "applyId": normalized_record_id,
143
+ "appKey": app_key,
144
+ "queryQuestions": [{"queId": code_block.que_id, "ordinal": None}],
145
+ "keyQueValues": key_que_values,
146
+ }
147
+ if workflow_node_id is not None:
148
+ run_body["auditNodeId"] = workflow_node_id
149
+ run_result = self.backend.request(
150
+ "POST",
151
+ context,
152
+ f"/app/{app_key}/codeBlock/working",
153
+ json_body=run_body,
154
+ )
155
+ alias_results = _normalize_code_block_alias_results(run_result)
156
+ relation_target_fields = _collect_code_block_relation_targets(
157
+ _collect_question_relations(schema),
158
+ code_block_que_id=code_block.que_id,
159
+ )
160
+ relation_errors: list[JSONObject] = []
161
+ relation_items: list[JSONObject] = []
162
+ calculated_answers: list[JSONObject] = []
163
+ relation_result: JSONObject | None = None
164
+ if relation_target_fields:
165
+ relation_body: JSONObject = {
166
+ "role": role,
167
+ "manual": bool(manual),
168
+ "applyId": normalized_record_id,
169
+ "appKey": app_key,
170
+ "queryQuestions": [{"queId": target["que_id"], "ordinal": None} for target in relation_target_fields],
171
+ "keyQueValues": key_que_values,
172
+ "codeBlockValues": [{"queId": code_block.que_id, "values": alias_results}],
173
+ }
174
+ if workflow_node_id is not None:
175
+ relation_body["auditNodeId"] = workflow_node_id
176
+ relation_result = self.backend.request("POST", context, "/que/actuator", json_body=relation_body)
177
+ relation_items = _relation_result_items(relation_result)
178
+ relation_errors = _relation_result_errors(relation_items)
179
+ calculated_answers = _relation_result_answers(relation_items)
180
+ write_result: JSONObject | None = None
181
+ verification: JSONObject | None = None
182
+ writeback_attempted = False
183
+ writeback_applied = False
184
+ status = "completed"
185
+ ok = True
186
+ if relation_target_fields and calculated_answers:
187
+ write_body: JSONObject = {"role": role, "answers": calculated_answers}
188
+ if workflow_node_id is not None:
189
+ write_body["auditNodeId"] = workflow_node_id
190
+ writeback_attempted = True
191
+ write_result = cast(
192
+ JSONObject,
193
+ self.backend.request(
194
+ "POST",
195
+ context,
196
+ f"/app/{app_key}/apply/{normalized_record_id}",
197
+ json_body=write_body,
198
+ ),
199
+ )
200
+ writeback_applied = True
201
+ if verify_writeback:
202
+ verification = self._verify_code_block_writeback_result(
203
+ context,
204
+ app_key=app_key,
205
+ apply_id=normalized_record_id,
206
+ expected_answers=calculated_answers,
207
+ index=index,
208
+ role=role,
209
+ audit_node_id=workflow_node_id,
210
+ )
211
+ if not bool(verification.get("verified")):
212
+ status = "verification_failed"
213
+ ok = False
214
+ elif relation_errors:
215
+ status = "relation_failed"
216
+ ok = False
217
+ else:
218
+ status = "no_writeback"
219
+ response: JSONObject = {
220
+ "profile": profile,
221
+ "ws_id": session_profile.selected_ws_id,
222
+ "request_route": self._request_route_payload(context),
223
+ "app_key": app_key,
224
+ "record_id": normalized_record_id,
225
+ "apply_id": normalized_record_id,
226
+ "status": status,
227
+ "ok": ok,
228
+ "code_block_field": _field_ref_payload(code_block),
229
+ "execution": {
230
+ "executed": True,
231
+ "role": role,
232
+ "workflow_node_id": workflow_node_id,
233
+ "manual": bool(manual),
234
+ "result_count": len(alias_results),
235
+ },
236
+ "outputs": {
237
+ "alias_results": alias_results,
238
+ "alias_map": _build_alias_result_map(alias_results),
239
+ },
240
+ "relation": {
241
+ "target_fields": relation_target_fields,
242
+ "result_item_count": len(relation_items),
243
+ "calculated_answer_count": len(calculated_answers),
244
+ "errors": relation_errors,
245
+ },
246
+ "writeback": {
247
+ "attempted": writeback_attempted,
248
+ "applied": writeback_applied,
249
+ "verify_writeback": verify_writeback,
250
+ "write_verified": bool(verification.get("verified")) if verification is not None else None,
251
+ "result": write_result,
252
+ "verification": verification,
253
+ },
254
+ "resource": {"apply_id": normalized_record_id},
255
+ }
256
+ if normalized_output_profile == "verbose":
257
+ response["debug"] = {
258
+ "run_body": run_body,
259
+ "relation_result": relation_result,
260
+ "calculated_answers": calculated_answers,
261
+ "merged_answers": merged_answers,
262
+ "key_que_values": key_que_values,
263
+ }
264
+ return response
265
+
266
+ return self._run_record_tool(profile, runner)
267
+
268
+ def _load_record_answers_for_code_block(
269
+ self,
270
+ context, # type: ignore[no-untyped-def]
271
+ *,
272
+ app_key: str,
273
+ apply_id: int,
274
+ role: int,
275
+ audit_node_id: int | None,
276
+ ) -> list[JSONObject]:
277
+ last_error: QingflowApiError | None = None
278
+ for list_type in self._INTERNAL_GET_LIST_TYPE_FALLBACKS:
279
+ params: JSONObject = {"role": role, "listType": list_type}
280
+ if audit_node_id is not None:
281
+ params["auditNodeId"] = audit_node_id
282
+ try:
283
+ record = self.backend.request("GET", context, f"/app/{app_key}/apply/{apply_id}", params=params)
284
+ answers = record.get("answers") if isinstance(record, dict) else None
285
+ return [item for item in answers if isinstance(item, dict)] if isinstance(answers, list) else []
286
+ except QingflowApiError as exc:
287
+ last_error = exc
288
+ if exc.backend_code == 40002:
289
+ continue
290
+ raise
291
+ if last_error is not None:
292
+ raise last_error
293
+ raise_tool_error(QingflowApiError.config_error("record answers could not be loaded for code-block execution"))
294
+
295
+ def _answers_to_open_match_values(self, answers: list[JSONObject], index: FieldIndex) -> list[JSONObject]:
296
+ values: list[JSONObject] = []
297
+ for answer in answers:
298
+ if not isinstance(answer, dict):
299
+ continue
300
+ open_match = self._answer_to_open_match_value(answer, index)
301
+ if open_match is None:
302
+ continue
303
+ values.append(open_match)
304
+ return values
305
+
306
+ def _answer_to_open_match_value(self, answer: JSONObject, index: FieldIndex) -> JSONObject | None:
307
+ que_id = _coerce_count(answer.get("queId", answer.get("que_id")))
308
+ if que_id is None or que_id <= 0:
309
+ return None
310
+ field = index.by_id.get(str(que_id))
311
+ if field is None:
312
+ return None
313
+ ordinal = _coerce_count(answer.get("ordinal"))
314
+ if field.que_type in SUBTABLE_QUE_TYPES:
315
+ rows = answer.get("tableValues")
316
+ row_values: list[list[JSONObject]] = []
317
+ subtable_index = self._subtable_field_index_optional(field)
318
+ if isinstance(rows, list) and subtable_index is not None:
319
+ for row in rows:
320
+ if not isinstance(row, list):
321
+ continue
322
+ normalized_row: list[JSONObject] = []
323
+ for cell in row:
324
+ if not isinstance(cell, dict):
325
+ continue
326
+ converted = self._answer_to_open_match_value(cell, subtable_index)
327
+ if converted is not None:
328
+ normalized_row.append(converted)
329
+ row_values.append(normalized_row)
330
+ payload: JSONObject = {"keyQueId": field.que_id, "ordinal": ordinal, "values": [], "tableValues": row_values}
331
+ return payload
332
+ return {
333
+ "keyQueId": field.que_id,
334
+ "ordinal": ordinal,
335
+ "values": self._answer_values_to_code_block_values(answer, field),
336
+ }
337
+
338
+ def _answer_values_to_code_block_values(self, answer: JSONObject, field: FormField) -> list[str]:
339
+ if field.que_type in RELATION_QUE_TYPES:
340
+ return _relation_ids_from_answer(answer)
341
+ raw_values = answer.get("values")
342
+ if not isinstance(raw_values, list):
343
+ return []
344
+ normalized: list[str] = []
345
+ for item in raw_values:
346
+ normalized_value = _normalize_code_block_value_item(item, field)
347
+ if normalized_value is None:
348
+ continue
349
+ normalized.append(normalized_value)
350
+ return normalized
351
+
352
+ def _verify_code_block_writeback_result(
353
+ self,
354
+ context, # type: ignore[no-untyped-def]
355
+ *,
356
+ app_key: str,
357
+ apply_id: int,
358
+ expected_answers: list[JSONObject],
359
+ index: FieldIndex,
360
+ role: int,
361
+ audit_node_id: int | None,
362
+ ) -> JSONObject:
363
+ if role == 1 and audit_node_id is None:
364
+ return self._verify_record_write_result(
365
+ context,
366
+ app_key=app_key,
367
+ apply_id=apply_id,
368
+ normalized_answers=expected_answers,
369
+ index=index,
370
+ verify_list_type=DEFAULT_RECORD_LIST_TYPE,
371
+ )
372
+ actual_answers = self._load_record_answers_for_code_block(
373
+ context,
374
+ app_key=app_key,
375
+ apply_id=apply_id,
376
+ role=role,
377
+ audit_node_id=audit_node_id,
378
+ )
379
+ actual_by_id = {
380
+ que_id: item
381
+ for item in actual_answers
382
+ if isinstance(item, dict) and (que_id := _coerce_count(item.get("queId"))) is not None
383
+ }
384
+ missing_fields: list[JSONObject] = []
385
+ empty_fields: list[JSONObject] = []
386
+ count_mismatches: list[JSONObject] = []
387
+ for answer in expected_answers:
388
+ que_id = _coerce_count(answer.get("queId"))
389
+ if que_id is None or que_id <= 0:
390
+ continue
391
+ actual = actual_by_id.get(que_id)
392
+ field = index.by_id.get(str(que_id))
393
+ field_payload = _field_ref_payload(field) if field is not None else {"que_id": que_id}
394
+ if actual is None:
395
+ missing_fields.append(field_payload)
396
+ continue
397
+ expected_rows = answer.get("tableValues") if isinstance(answer.get("tableValues"), list) else []
398
+ if expected_rows:
399
+ actual_rows = actual.get("tableValues") if isinstance(actual.get("tableValues"), list) else []
400
+ self._verify_subtable_write_result(
401
+ field=field,
402
+ expected_rows=expected_rows,
403
+ actual_rows=actual_rows,
404
+ missing_fields=missing_fields,
405
+ empty_fields=empty_fields,
406
+ count_mismatches=count_mismatches,
407
+ )
408
+ continue
409
+ if field is not None and field.que_type in RELATION_QUE_TYPES:
410
+ expected_relation_ids = _relation_ids_from_answer(answer)
411
+ actual_relation_ids = _relation_ids_from_answer(actual)
412
+ if expected_relation_ids and not actual_relation_ids:
413
+ empty_fields.append(field_payload)
414
+ continue
415
+ if expected_relation_ids:
416
+ actual_id_set = set(actual_relation_ids)
417
+ missing_ids = [value for value in expected_relation_ids if value not in actual_id_set]
418
+ if missing_ids:
419
+ count_mismatches.append(
420
+ {
421
+ **field_payload,
422
+ "expected_ids": expected_relation_ids,
423
+ "actual_ids": actual_relation_ids,
424
+ "missing_ids": missing_ids,
425
+ }
426
+ )
427
+ continue
428
+ actual_values = actual.get("values") if isinstance(actual.get("values"), list) else []
429
+ if not actual_values:
430
+ empty_fields.append(field_payload)
431
+ continue
432
+ expected_values = answer.get("values") if isinstance(answer.get("values"), list) else []
433
+ if expected_values and len(actual_values) < len(expected_values):
434
+ count_mismatches.append(
435
+ {
436
+ **field_payload,
437
+ "expected_count": len(expected_values),
438
+ "actual_count": len(actual_values),
439
+ }
440
+ )
441
+ return {
442
+ "verified": not missing_fields and not empty_fields and not count_mismatches,
443
+ "verification_mode": "role_record_view",
444
+ "field_level_verified": True,
445
+ "missing_fields": missing_fields,
446
+ "empty_fields": empty_fields,
447
+ "count_mismatches": count_mismatches,
448
+ }
449
+
450
+
451
+ def _normalize_code_block_value_item(value: JSONValue, field: FormField) -> str | None:
452
+ if field.que_type in SINGLE_SELECT_QUE_TYPES | MULTI_SELECT_QUE_TYPES:
453
+ return _selector_numeric_or_text(value, ("optionId", "optId", "id"), allow_text=False)
454
+ if field.que_type in MEMBER_QUE_TYPES:
455
+ return _selector_numeric_or_text(value, ("id", "uid"), allow_text=False)
456
+ if field.que_type in DEPARTMENT_QUE_TYPES:
457
+ return _selector_numeric_or_text(value, ("id", "deptId"), allow_text=False)
458
+ if field.que_type in ATTACHMENT_QUE_TYPES:
459
+ return _selector_numeric_or_text(value, ("value", "url", "otherInfo", "name", "fileName"), allow_text=True)
460
+ if isinstance(value, dict):
461
+ scalar = value.get("value")
462
+ return _stringify_json(scalar) if scalar is not None else None
463
+ text = _normalize_optional_text(value)
464
+ return text if text is not None else None
465
+
466
+
467
+ def _selector_numeric_or_text(value: JSONValue, keys: tuple[str, ...], *, allow_text: bool) -> str | None:
468
+ numeric = _coerce_count(value)
469
+ if numeric is not None:
470
+ return str(numeric)
471
+ if not isinstance(value, dict):
472
+ if not allow_text:
473
+ return None
474
+ text = _normalize_optional_text(value)
475
+ return text if text is not None else None
476
+ for key in keys:
477
+ if key not in value:
478
+ continue
479
+ candidate = value.get(key)
480
+ if candidate is None:
481
+ continue
482
+ numeric = _coerce_count(candidate)
483
+ if numeric is not None:
484
+ return str(numeric)
485
+ if allow_text:
486
+ text = _normalize_optional_text(candidate)
487
+ if text is not None:
488
+ return text
489
+ return _normalize_optional_text(value.get("value")) if allow_text and isinstance(value.get("value"), (str, int, float)) else None
490
+
491
+
492
+ def _normalize_code_block_alias_results(payload: JSONValue) -> list[JSONObject]:
493
+ if not isinstance(payload, dict):
494
+ return []
495
+ raw_results = payload.get("result")
496
+ if not isinstance(raw_results, list):
497
+ return []
498
+ results: list[JSONObject] = []
499
+ for item in raw_results:
500
+ if not isinstance(item, dict):
501
+ continue
502
+ values = item.get("value")
503
+ result: JSONObject = {
504
+ "parentAliasId": _coerce_count(item.get("parentAliasId")),
505
+ "parentAlias": _normalize_optional_text(item.get("parentAlias")),
506
+ "aliasId": _coerce_count(item.get("aliasId")),
507
+ "alias": _normalize_optional_text(item.get("alias")),
508
+ "value": [_stringify_json(entry) for entry in values] if isinstance(values, list) else [],
509
+ }
510
+ results.append(result)
511
+ return results
512
+
513
+
514
+ def _build_alias_result_map(alias_results: list[JSONObject]) -> JSONObject:
515
+ alias_map: JSONObject = {}
516
+ for item in alias_results:
517
+ alias = _normalize_optional_text(item.get("alias"))
518
+ if alias is None:
519
+ continue
520
+ parent_alias = _normalize_optional_text(item.get("parentAlias"))
521
+ key = f"{parent_alias}.{alias}" if parent_alias else alias
522
+ values = item.get("value")
523
+ alias_map[key] = values if isinstance(values, list) else []
524
+ return alias_map
525
+
526
+
527
+ def _collect_code_block_relation_targets(question_relations: list[JSONObject], *, code_block_que_id: int) -> list[JSONObject]:
528
+ targets: list[JSONObject] = []
529
+ seen: set[int] = set()
530
+ for relation in question_relations:
531
+ if _coerce_count(relation.get("relationType")) != CODE_BLOCK_RELATION_TYPE:
532
+ continue
533
+ if _coerce_count(relation.get("qlinkerQueId")) != code_block_que_id:
534
+ continue
535
+ target_id = _coerce_count(
536
+ relation.get("queId", relation.get("targetQueId", relation.get("displayedQueId")))
537
+ )
538
+ if target_id is None or target_id in seen:
539
+ continue
540
+ seen.add(target_id)
541
+ targets.append(
542
+ {
543
+ "que_id": target_id,
544
+ "alias_id": _coerce_count(relation.get("aliasId")),
545
+ "qlinker_que_id": _coerce_count(relation.get("qlinkerQueId")),
546
+ }
547
+ )
548
+ return targets
549
+
550
+
551
+ def _relation_result_items(payload: JSONValue) -> list[JSONObject]:
552
+ if not isinstance(payload, dict):
553
+ return []
554
+ result = payload.get("result")
555
+ return [item for item in result if isinstance(item, dict)] if isinstance(result, list) else []
556
+
557
+
558
+ def _relation_result_answers(items: list[JSONObject]) -> list[JSONObject]:
559
+ answers: list[JSONObject] = []
560
+ for item in items:
561
+ raw_answers = item.get("answers")
562
+ if not isinstance(raw_answers, list):
563
+ continue
564
+ for answer in raw_answers:
565
+ if isinstance(answer, dict):
566
+ answers.append(answer)
567
+ return answers
568
+
569
+
570
+ def _relation_result_errors(items: list[JSONObject]) -> list[JSONObject]:
571
+ errors: list[JSONObject] = []
572
+ for item in items:
573
+ message = _normalize_optional_text(item.get("errorMsg"))
574
+ if message is None:
575
+ continue
576
+ errors.append(
577
+ {
578
+ "que_id": _coerce_count(item.get("queId")),
579
+ "que_title": _normalize_optional_text(item.get("queTitle")),
580
+ "ordinal": _coerce_count(item.get("ordinal")),
581
+ "message": message,
582
+ }
583
+ )
584
+ return errors
@@ -95,6 +95,32 @@ class QingbiReportTools(ToolBase):
95
95
  self._require_app_key(app_key)
96
96
  return self._request(profile, "GET", f"/qingbi/charts/data/bichart/{app_key}", app_key=app_key, items_key="items")
97
97
 
98
+ def qingbi_report_list_sorted(
99
+ self,
100
+ *,
101
+ profile: str,
102
+ app_key: str,
103
+ page_num: int = 1,
104
+ page_size: int = 500,
105
+ search_key: str | None = None,
106
+ ) -> JSONObject:
107
+ self._require_app_key(app_key)
108
+ body: JSONObject = {"appKey": app_key, "pageNum": page_num, "pageSize": page_size}
109
+ if search_key:
110
+ body["searchKey"] = search_key
111
+ return self._request(
112
+ profile,
113
+ "POST",
114
+ "/qingbi/charts/chart/list",
115
+ app_key=app_key,
116
+ page_num=page_num,
117
+ page_size=page_size,
118
+ search_key=search_key,
119
+ items_key="items",
120
+ result_transform=_extract_sorted_chart_items,
121
+ json_body=body,
122
+ )
123
+
98
124
  def qingbi_report_create(self, *, profile: str, payload: JSONObject) -> JSONObject:
99
125
  body = self._require_dict(payload)
100
126
  return self._request(profile, "POST", "/qingbi/charts", json_body=body)
@@ -233,3 +259,11 @@ def _extract_dataset_fields(result: JSONValue) -> list[JSONObject]:
233
259
  if isinstance(result, list):
234
260
  return result
235
261
  return []
262
+
263
+
264
+ def _extract_sorted_chart_items(result: JSONValue) -> list[JSONObject]:
265
+ if isinstance(result, dict) and isinstance(result.get("list"), list):
266
+ return result["list"]
267
+ if isinstance(result, list):
268
+ return result
269
+ return []