@josephyan/qingflow-app-user-mcp 0.2.0-beta.28 → 0.2.0-beta.29

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.28
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.29
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.28 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.29 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.28",
3
+ "version": "0.2.0-beta.29",
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.0b28"
7
+ version = "0.2.0b29"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -42,6 +42,8 @@ Use exactly one of these default paths:
42
42
  - `file_get_upload_info`
43
43
  - `file_upload_local`
44
44
 
45
+ Use `record_member_candidates` / `record_department_candidates` as the default lookup path for member or department fields. Treat `directory_*` as org-browse or fallback tooling, not the primary field-candidate path.
46
+
45
47
  ## Standard Operating Order
46
48
 
47
49
  1. Ensure auth exists
@@ -102,6 +104,7 @@ The DSL is clause-shaped like SQL, but it is **not raw SQL text**.
102
104
  - Do not auto-resolve relation targets without first querying them
103
105
  - Do not assume member display names resolve automatically; `record_member_candidates` returns ids, but direct name-to-id write is not automatic
104
106
  - Do not assume department names resolve automatically; `record_department_candidates` returns ids, but direct name-to-id write is not automatic
107
+ - For default-all member or department fields, prefer the field candidate tools; do not start with `directory_*`
105
108
  - Do not assume `record_schema_get` is a builder/full-field schema.
106
109
 
107
110
  ### Complex field quick examples
@@ -2,4 +2,4 @@ from __future__ import annotations
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0b28"
5
+ __version__ = "0.2.0b29"
@@ -90,6 +90,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
90
90
 
91
91
  - Read relation targets from `record_schema_get.target_app_key` / `target_app_name` before preparing relation writes.
92
92
  - 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`.
93
+ - For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
93
94
 
94
95
  ## Task Center Path
95
96
 
@@ -78,6 +78,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
78
78
 
79
79
  - Read relation targets from `record_schema_get.target_app_key` / `target_app_name` before preparing relation writes.
80
80
  - 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`.
81
+ - For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
81
82
 
82
83
  ## Task Center Path
83
84
 
@@ -1091,9 +1091,9 @@ class RecordTools(ToolBase):
1091
1091
  if _scope_is_default_all(scope_type, scope, keys=("member", "depart", "role")):
1092
1092
  candidates = [
1093
1093
  _normalize_candidate_member(item, source_kind="workspace")
1094
- for item in self._fetch_internal_members(context, department_id=None, role_id=None)
1094
+ for item in self._search_workspace_members(context, keyword=keyword)
1095
1095
  ]
1096
- filtered = _filter_member_candidates([item for item in candidates if item is not None], keyword)
1096
+ filtered = [item for item in candidates if item is not None]
1097
1097
  filtered.sort(key=lambda item: (_normalize_optional_text(item.get("value")) or "", _coerce_count(item.get("id")) or 0))
1098
1098
  return filtered
1099
1099
  if scope_type != 1:
@@ -1187,7 +1187,7 @@ class RecordTools(ToolBase):
1187
1187
  merged: dict[str, JSONObject] = {}
1188
1188
  include_sub = _normalize_bool(scope.get("includeSubDeparts"))
1189
1189
  if _scope_is_default_all(scope_type, scope, keys=("depart",)):
1190
- for item in self._list_all_departments(context):
1190
+ for item in self._search_workspace_departments(context, keyword=keyword):
1191
1191
  normalized = _normalize_candidate_department(item, source_kind="workspace")
1192
1192
  if normalized is not None:
1193
1193
  self._merge_department_candidate(merged, normalized)
@@ -1404,6 +1404,56 @@ class RecordTools(ToolBase):
1404
1404
  current_page += 1
1405
1405
  return list(seen.values())
1406
1406
 
1407
+ def _search_workspace_members(self, context, *, keyword: str) -> list[dict[str, Any]]: # type: ignore[no-untyped-def]
1408
+ return self._search_workspace_directory_dimension(context, dimension="MEMBER", bucket_key="member", keyword=keyword)
1409
+
1410
+ def _search_workspace_departments(self, context, *, keyword: str) -> list[dict[str, Any]]: # type: ignore[no-untyped-def]
1411
+ return self._search_workspace_directory_dimension(context, dimension="DEPT", bucket_key="dept", keyword=keyword)
1412
+
1413
+ def _search_workspace_directory_dimension(
1414
+ self,
1415
+ context, # type: ignore[no-untyped-def]
1416
+ *,
1417
+ dimension: str,
1418
+ bucket_key: str,
1419
+ keyword: str,
1420
+ ) -> list[dict[str, Any]]:
1421
+ current_page = 1
1422
+ fetched_pages = 0
1423
+ seen: dict[str, dict[str, Any]] = {}
1424
+ while fetched_pages < MAX_MEMBER_SCOPE_FETCH_PAGES:
1425
+ result = self.backend.request(
1426
+ "POST",
1427
+ context,
1428
+ "/member/search",
1429
+ json_body={
1430
+ "dimensions": [dimension],
1431
+ "searchKey": keyword,
1432
+ "pageNum": current_page,
1433
+ "pageSize": DEFAULT_MEMBER_SCOPE_FETCH_PAGE_SIZE,
1434
+ },
1435
+ )
1436
+ bucket_payload = _member_search_bucket_payload(result, bucket_key=bucket_key)
1437
+ page_items = _directory_items(bucket_payload)
1438
+ for item in page_items:
1439
+ if not isinstance(item, dict):
1440
+ continue
1441
+ normalized = dict(item)
1442
+ item_key = _member_candidate_key(normalized) if bucket_key == "member" else _department_candidate_key(normalized)
1443
+ if not item_key or item_key in seen:
1444
+ continue
1445
+ seen[item_key] = normalized
1446
+ fetched_pages += 1
1447
+ if not _directory_has_more(
1448
+ bucket_payload,
1449
+ current_page=current_page,
1450
+ page_size=DEFAULT_MEMBER_SCOPE_FETCH_PAGE_SIZE,
1451
+ returned_items=len(page_items),
1452
+ ):
1453
+ break
1454
+ current_page += 1
1455
+ return list(seen.values())
1456
+
1407
1457
  def _schema_field_family(self, field: FormField) -> str:
1408
1458
  if self._schema_is_identifier_like(field):
1409
1459
  return "text"
@@ -3356,6 +3406,8 @@ class RecordTools(ToolBase):
3356
3406
  self._raise_if_verify_unsupported_write_field(field, item, location=field.que_title)
3357
3407
  if "values" in item and isinstance(item["values"], list):
3358
3408
  values = item["values"]
3409
+ elif field.que_type in RELATION_QUE_TYPES and isinstance(item.get("referValues"), list):
3410
+ values = item["referValues"]
3359
3411
  elif "value" in item:
3360
3412
  values = [item["value"]]
3361
3413
  else:
@@ -3365,6 +3417,8 @@ class RecordTools(ToolBase):
3365
3417
  fix_hint="Pass value for scalar fields, or values for multi-value fields.",
3366
3418
  details={"location": field.que_title, "field": _field_ref_payload(field), "expected_format": _write_format_for_field(field)},
3367
3419
  )
3420
+ if field.que_type in RELATION_QUE_TYPES:
3421
+ return self._build_relation_answer(field, values)
3368
3422
  return {
3369
3423
  "queId": field.que_id,
3370
3424
  "queType": field.que_type or 2,
@@ -3383,6 +3437,8 @@ class RecordTools(ToolBase):
3383
3437
  }
3384
3438
  self._raise_if_verify_unsupported_write_field(field, raw_value, location=str(field_selector))
3385
3439
  values = raw_value if isinstance(raw_value, list) and field.que_type in MULTI_SELECT_QUE_TYPES else [raw_value]
3440
+ if field.que_type in RELATION_QUE_TYPES:
3441
+ return self._build_relation_answer(field, values)
3386
3442
  return {
3387
3443
  "queId": field.que_id,
3388
3444
  "queType": field.que_type or 2,
@@ -3390,6 +3446,21 @@ class RecordTools(ToolBase):
3390
3446
  "tableValues": [],
3391
3447
  }
3392
3448
 
3449
+ def _build_relation_answer(self, field: FormField, raw_values: list[JSONValue]) -> JSONObject:
3450
+ values: list[JSONObject] = []
3451
+ refer_values: list[JSONObject] = []
3452
+ for raw_value in _expand_values(raw_values):
3453
+ value_payload, refer_payload = _relation_value_payload(field, raw_value)
3454
+ values.append(value_payload)
3455
+ refer_values.append(refer_payload)
3456
+ return {
3457
+ "queId": field.que_id,
3458
+ "queType": field.que_type or 25,
3459
+ "values": values,
3460
+ "referValues": refer_values,
3461
+ "tableValues": [],
3462
+ }
3463
+
3393
3464
  def _raise_if_verify_unsupported_write_field(self, field: FormField, raw_value: JSONValue, *, location: str) -> None:
3394
3465
  if field.que_type not in VERIFY_UNSUPPORTED_WRITE_QUE_TYPES:
3395
3466
  return
@@ -4431,6 +4502,25 @@ class RecordTools(ToolBase):
4431
4502
  count_mismatches=count_mismatches,
4432
4503
  )
4433
4504
  continue
4505
+ if field is not None and field.que_type in RELATION_QUE_TYPES:
4506
+ expected_relation_ids = _relation_ids_from_answer(answer)
4507
+ actual_relation_ids = _relation_ids_from_answer(actual)
4508
+ if expected_relation_ids and not actual_relation_ids:
4509
+ empty_fields.append(field_payload)
4510
+ continue
4511
+ if expected_relation_ids:
4512
+ actual_id_set = set(actual_relation_ids)
4513
+ missing_ids = [value for value in expected_relation_ids if value not in actual_id_set]
4514
+ if missing_ids:
4515
+ count_mismatches.append(
4516
+ {
4517
+ **field_payload,
4518
+ "expected_ids": expected_relation_ids,
4519
+ "actual_ids": actual_relation_ids,
4520
+ "missing_ids": missing_ids,
4521
+ }
4522
+ )
4523
+ continue
4434
4524
  actual_values = actual.get("values") if isinstance(actual.get("values"), list) else []
4435
4525
  if not actual_values:
4436
4526
  empty_fields.append(field_payload)
@@ -5914,7 +6004,24 @@ def _attachment_value(value: JSONValue) -> JSONObject:
5914
6004
 
5915
6005
 
5916
6006
  def _relation_value(value: JSONValue) -> JSONObject:
6007
+ return _relation_value_payload(None, value)[0]
6008
+
6009
+
6010
+ def _relation_value_payload(field: FormField | None, value: JSONValue) -> tuple[JSONObject, JSONObject]:
5917
6011
  if isinstance(value, dict):
6012
+ target_app_key = _normalize_optional_text(value.get("target_app_key", value.get("targetAppKey", value.get("app_key", value.get("appKey")))))
6013
+ if field is not None and field.target_app_key and target_app_key and target_app_key != field.target_app_key:
6014
+ raise RecordInputError(
6015
+ message=f"relation field '{field.que_title}' points to a different target app",
6016
+ error_code="RELATION_TARGET_APP_MISMATCH",
6017
+ fix_hint=f"Use a record from target app '{field.target_app_key}'.",
6018
+ details={
6019
+ "field": _field_ref_payload(field),
6020
+ "expected_target_app_key": field.target_app_key,
6021
+ "received_target_app_key": target_app_key,
6022
+ "received_value": value,
6023
+ },
6024
+ )
5918
6025
  apply_id = value.get("apply_id", value.get("applyId", value.get("value", value.get("id"))))
5919
6026
  if apply_id is None:
5920
6027
  raise RecordInputError(
@@ -5923,8 +6030,37 @@ def _relation_value(value: JSONValue) -> JSONObject:
5923
6030
  fix_hint="Pass relation values like {'apply_id': 5001} or numeric apply ids.",
5924
6031
  details={"received_value": value},
5925
6032
  )
5926
- return {"value": _stringify_json(apply_id)}
5927
- return {"value": _stringify_json(value)}
6033
+ normalized_apply_id = _stringify_json(apply_id)
6034
+ return ({"value": normalized_apply_id}, {"applyId": normalized_apply_id})
6035
+ normalized_apply_id = _stringify_json(value)
6036
+ return ({"value": normalized_apply_id}, {"applyId": normalized_apply_id})
6037
+
6038
+
6039
+ def _relation_ids_from_answer(answer: JSONObject) -> list[str]:
6040
+ ids: list[str] = []
6041
+ seen: set[str] = set()
6042
+ for key in ("referValues", "values"):
6043
+ values = answer.get(key)
6044
+ if not isinstance(values, list):
6045
+ continue
6046
+ for item in values:
6047
+ if not isinstance(item, dict):
6048
+ continue
6049
+ relation_id = item.get("applyId", item.get("apply_id", item.get("value", item.get("id"))))
6050
+ normalized = _normalize_optional_text(relation_id) or (_stringify_json(relation_id) if relation_id is not None else None)
6051
+ if not normalized or normalized in seen:
6052
+ continue
6053
+ ids.append(normalized)
6054
+ seen.add(normalized)
6055
+ return ids
6056
+
6057
+
6058
+ def _member_search_bucket_payload(payload: JSONValue, *, bucket_key: str) -> JSONValue:
6059
+ if isinstance(payload, dict):
6060
+ bucket = payload.get(bucket_key)
6061
+ if isinstance(bucket, (dict, list)):
6062
+ return bucket
6063
+ return payload
5928
6064
 
5929
6065
 
5930
6066
  def _normalize_candidate_member(
@@ -6265,8 +6401,8 @@ def _write_format_for_field(field: FormField) -> JSONObject:
6265
6401
  return _write_support_payload(
6266
6402
  support_level="restricted",
6267
6403
  kind="relation_record",
6268
- examples=[{"value": "5001"}],
6269
- required_presteps=["Query the target app first and use the referenced apply_id."],
6404
+ examples=[{"apply_id": 5001}],
6405
+ required_presteps=["Query the target app first and use the referenced apply_id from the target app."],
6270
6406
  )
6271
6407
  if field.que_type in SINGLE_SELECT_QUE_TYPES:
6272
6408
  return _write_support_payload(support_level="full", kind="single_select", options=field.options)