@josephyan/qingflow-app-user-mcp 0.2.0-beta.25 → 0.2.0-beta.26

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.25
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.26
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.25 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.26 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.25",
3
+ "version": "0.2.0-beta.26",
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.0b25"
7
+ version = "0.2.0b26"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,4 +2,4 @@ from __future__ import annotations
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0b25"
5
+ __version__ = "0.2.0b26"
@@ -303,15 +303,57 @@ class DirectoryTools(ToolBase):
303
303
  page_num: int,
304
304
  page_size: int,
305
305
  ) -> dict[str, Any]:
306
- if not keyword:
307
- 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
+ )
308
350
 
309
351
  def runner(session_profile, context):
310
352
  result = self.backend.request(
311
353
  "GET",
312
354
  context,
313
355
  "/contact/deptByPage",
314
- params={"keyword": keyword, "pageNum": page_num, "pageSize": page_size},
356
+ params={"keyword": normalized_keyword, "pageNum": page_num, "pageSize": page_size},
315
357
  )
316
358
  return {
317
359
  "profile": profile,
@@ -332,7 +374,7 @@ class DirectoryTools(ToolBase):
332
374
  "reported_total": _coerce_int(_payload_value(raw.get("page"), "total")),
333
375
  "page_amount": _coerce_int(_payload_value(raw.get("page"), "pageAmount")),
334
376
  },
335
- selection={"keyword": keyword},
377
+ selection={"keyword": normalized_keyword},
336
378
  )
337
379
 
338
380
  def directory_list_all_departments(
@@ -1686,6 +1686,9 @@ class RecordTools(ToolBase):
1686
1686
  support_matrix = _summarize_write_support(resolved_fields)
1687
1687
  invalid_fields: list[JSONObject] = []
1688
1688
  normalized_answers: list[JSONObject] = []
1689
+ validation_warnings = [
1690
+ "record_write performs static preflight from form metadata before apply; runtime visibility and dynamic linkage can still reject writes."
1691
+ ]
1689
1692
  try:
1690
1693
  normalized_answers = self._resolve_answers(
1691
1694
  profile,
@@ -1706,6 +1709,16 @@ class RecordTools(ToolBase):
1706
1709
  "received_value": error.details.get("received_value") if error.details else None,
1707
1710
  }
1708
1711
  )
1712
+ validation_answers = normalized_answers
1713
+ if operation == "update" and apply_id is not None and not invalid_fields:
1714
+ try:
1715
+ existing_answers = self._load_record_answers_for_preflight(context, app_key=app_key, apply_id=apply_id)
1716
+ except QingflowApiError:
1717
+ validation_warnings.append(
1718
+ "update preflight could not load the current record; required-field completeness was not revalidated."
1719
+ )
1720
+ else:
1721
+ validation_answers = self._merge_record_answers(existing_answers, normalized_answers)
1709
1722
  readonly_or_system_fields = [
1710
1723
  {
1711
1724
  "que_id": entry.get("que_id"),
@@ -1721,8 +1734,9 @@ class RecordTools(ToolBase):
1721
1734
  ]
1722
1735
  provided_field_ids = {
1723
1736
  str(answer.get("queId"))
1724
- for answer in normalized_answers
1737
+ for answer in validation_answers
1725
1738
  if isinstance(answer.get("queId"), int) and int(answer["queId"]) > 0
1739
+ and _answer_has_meaningful_content(answer)
1726
1740
  }
1727
1741
  missing_required_fields = []
1728
1742
  for field in index.by_id.values():
@@ -1738,9 +1752,6 @@ class RecordTools(ToolBase):
1738
1752
  )
1739
1753
  question_relations = _collect_question_relations(schema)
1740
1754
  option_links = _collect_option_links(resolved_fields)
1741
- validation_warnings = [
1742
- "record_write performs static preflight from form metadata before apply; runtime visibility and dynamic linkage can still reject writes."
1743
- ]
1744
1755
  if question_relations:
1745
1756
  validation_warnings.append(
1746
1757
  "form contains questionRelations; linked visibility and runtime required rules may differ at submit time."
@@ -1795,6 +1806,39 @@ class RecordTools(ToolBase):
1795
1806
  "recommended_next_actions": actions,
1796
1807
  }
1797
1808
 
1809
+ def _load_record_answers_for_preflight(
1810
+ self,
1811
+ context, # type: ignore[no-untyped-def]
1812
+ *,
1813
+ app_key: str,
1814
+ apply_id: int,
1815
+ ) -> list[JSONObject]:
1816
+ record = self.backend.request(
1817
+ "GET",
1818
+ context,
1819
+ f"/app/{app_key}/apply/{apply_id}",
1820
+ params={"role": 1, "listType": DEFAULT_RECORD_LIST_TYPE},
1821
+ )
1822
+ answers = record.get("answers") if isinstance(record, dict) else None
1823
+ return [item for item in answers if isinstance(item, dict)] if isinstance(answers, list) else []
1824
+
1825
+ def _merge_record_answers(
1826
+ self,
1827
+ existing_answers: list[JSONObject],
1828
+ patch_answers: list[JSONObject],
1829
+ ) -> list[JSONObject]:
1830
+ merged_by_id: dict[int, JSONObject] = {}
1831
+ order: list[int] = []
1832
+ for source in (existing_answers, patch_answers):
1833
+ for item in source:
1834
+ que_id = _coerce_count(item.get("queId")) if isinstance(item, dict) else None
1835
+ if que_id is None or que_id <= 0:
1836
+ continue
1837
+ if que_id not in merged_by_id:
1838
+ order.append(que_id)
1839
+ merged_by_id[que_id] = item
1840
+ return [merged_by_id[que_id] for que_id in order]
1841
+
1798
1842
  def record_query(
1799
1843
  self,
1800
1844
  *,
@@ -3946,6 +3990,26 @@ def _normalize_audit_nodes(payload: JSONValue) -> list[JSONObject]:
3946
3990
  return []
3947
3991
 
3948
3992
 
3993
+ def _answer_has_meaningful_content(answer: JSONObject) -> bool:
3994
+ table_values = answer.get("tableValues")
3995
+ if isinstance(table_values, list) and table_values:
3996
+ for row in table_values:
3997
+ if isinstance(row, list) and any(_answer_has_meaningful_content(item) for item in row if isinstance(item, dict)):
3998
+ return True
3999
+ return False
4000
+ values = answer.get("values")
4001
+ if not isinstance(values, list) or not values:
4002
+ return False
4003
+ for item in values:
4004
+ if isinstance(item, dict):
4005
+ if any(value not in (None, "", [], {}) for value in item.values()):
4006
+ return True
4007
+ continue
4008
+ if item not in (None, "", [], {}):
4009
+ return True
4010
+ return False
4011
+
4012
+
3949
4013
  def _extract_applicant_node(payload: JSONValue) -> WorkflowNodeRef | None:
3950
4014
  for item in _normalize_audit_nodes(payload):
3951
4015
  node_type = _coerce_count(item.get("type"))