@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 +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-record-crud/SKILL.md +3 -0
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/server.py +1 -0
- package/src/qingflow_mcp/server_app_user.py +1 -0
- package/src/qingflow_mcp/tools/record_tools.py +143 -7
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.
|
|
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.
|
|
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
package/pyproject.toml
CHANGED
|
@@ -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
|
|
@@ -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.
|
|
1094
|
+
for item in self._search_workspace_members(context, keyword=keyword)
|
|
1095
1095
|
]
|
|
1096
|
-
filtered =
|
|
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.
|
|
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
|
-
|
|
5927
|
-
|
|
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=[{"
|
|
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)
|