@josephyan/qingflow-app-builder-mcp 0.2.0-beta.22 → 0.2.0-beta.24
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.
|
|
6
|
+
npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.24
|
|
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.
|
|
12
|
+
npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.24 qingflow-app-builder-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -30,8 +30,9 @@ def build_server() -> FastMCP:
|
|
|
30
30
|
"All resource tools operate with the logged-in user's Qingflow permissions.\n\n"
|
|
31
31
|
"For analytics, use record_schema_get first, let the model build field_id-based DSL, "
|
|
32
32
|
"then call record_analyze. record_analyze returns compact business-first output as query/result/ranking/ratios/completeness/presentation; use verbose only for route/debug details. "
|
|
33
|
+
"record_schema_get returns the current user's applicant-node visible schema only; hidden fields are omitted and missing fields should be treated as not visible in the current permission scope. "
|
|
33
34
|
"For operational record reads, use record_schema_get first, then record_list or record_get. "
|
|
34
|
-
"For writes, use record_schema_get and then call record_write once; it performs internal preflight before any apply.\n\n"
|
|
35
|
+
"For writes, use record_schema_get and then call record_write once; it performs internal preflight before any apply and refuses fields outside the applicant-node writable schema.\n\n"
|
|
35
36
|
"Task Center (待办/已办) handling:\n"
|
|
36
37
|
"- Use task_summary to get headline counts.\n"
|
|
37
38
|
"- Use task_list for flat task browsing with task_box and flow_status.\n"
|
|
@@ -20,6 +20,7 @@ def build_user_server() -> FastMCP:
|
|
|
20
20
|
instructions=(
|
|
21
21
|
"Use this server for Qingflow operational workflows with a schema-first path. "
|
|
22
22
|
"For records, start with record_schema_get, then choose record_list, record_get, or record_write. "
|
|
23
|
+
"record_schema_get returns the current user's applicant-node visible schema only; hidden fields are omitted and missing fields should be treated as not visible in the current permission scope. "
|
|
23
24
|
"For analytics, switch to record_schema_get and record_analyze; its default output is compact query/result/ranking/ratios/completeness/presentation, with route/debug only in verbose mode. "
|
|
24
25
|
"For task center, use task_summary, task_list, and task_facets before any explicit task action. "
|
|
25
26
|
"Avoid builder-side app or schema changes here."
|
|
@@ -38,6 +38,7 @@ ATTACHMENT_QUE_TYPES = {13}
|
|
|
38
38
|
RELATION_QUE_TYPES = {25}
|
|
39
39
|
SUBTABLE_QUE_TYPES = {18}
|
|
40
40
|
VERIFY_UNSUPPORTED_WRITE_QUE_TYPES = {14, 34, 35, 36}
|
|
41
|
+
LAYOUT_ONLY_QUE_TYPES = {24}
|
|
41
42
|
DEPARTMENT_MEMBER_JUDGE_PREFIX = "deptId_"
|
|
42
43
|
JUDGE_EQUAL = 0
|
|
43
44
|
JUDGE_UNEQUAL = 1
|
|
@@ -90,6 +91,14 @@ class ViewSelection:
|
|
|
90
91
|
conditions: list[list[ViewFilterCondition]]
|
|
91
92
|
|
|
92
93
|
|
|
94
|
+
@dataclass(slots=True)
|
|
95
|
+
class WorkflowNodeRef:
|
|
96
|
+
workflow_node_id: int
|
|
97
|
+
name: str
|
|
98
|
+
type: str
|
|
99
|
+
raw: JSONObject
|
|
100
|
+
|
|
101
|
+
|
|
93
102
|
@dataclass(slots=True)
|
|
94
103
|
class RecordInputError(Exception):
|
|
95
104
|
message: str
|
|
@@ -134,7 +143,8 @@ FIELD_LOOKUP_STRIP_RE = re.compile(r"[\s_()()\[\]【】{}<>·/\\::-]+")
|
|
|
134
143
|
class RecordTools(ToolBase):
|
|
135
144
|
def __init__(self, sessions, backend) -> None: # type: ignore[no-untyped-def]
|
|
136
145
|
super().__init__(sessions, backend)
|
|
137
|
-
self._form_cache: dict[tuple[str, str], JSONObject] = {}
|
|
146
|
+
self._form_cache: dict[tuple[str, str, str, int], JSONObject] = {}
|
|
147
|
+
self._applicant_node_cache: dict[tuple[str, str], WorkflowNodeRef] = {}
|
|
138
148
|
self._view_list_cache: dict[tuple[str, str], list[JSONObject]] = {}
|
|
139
149
|
self._view_config_cache: dict[tuple[str, str], JSONObject] = {}
|
|
140
150
|
|
|
@@ -286,9 +296,10 @@ class RecordTools(ToolBase):
|
|
|
286
296
|
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
287
297
|
|
|
288
298
|
def runner(session_profile, context):
|
|
299
|
+
applicant_node = self._resolve_applicant_node(profile, context, app_key, force_refresh=False)
|
|
289
300
|
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
290
301
|
view_selection = self._resolve_view_selection(profile, context, app_key, view_key=view_key, view_name=view_name)
|
|
291
|
-
fields = [self._schema_field_payload(field) for field in index.by_id.values()]
|
|
302
|
+
fields = [self._schema_field_payload(field, workflow_node_id=applicant_node.workflow_node_id) for field in index.by_id.values()]
|
|
292
303
|
suggested_dimensions = [
|
|
293
304
|
{"field_id": item["field_id"], "title": item["title"]}
|
|
294
305
|
for item in fields
|
|
@@ -312,6 +323,12 @@ class RecordTools(ToolBase):
|
|
|
312
323
|
"request_route": self._request_route_payload(context),
|
|
313
324
|
"data": {
|
|
314
325
|
"app_key": app_key,
|
|
326
|
+
"schema_scope": "applicant_node",
|
|
327
|
+
"workflow_node": {
|
|
328
|
+
"workflow_node_id": applicant_node.workflow_node_id,
|
|
329
|
+
"name": applicant_node.name,
|
|
330
|
+
"type": applicant_node.type,
|
|
331
|
+
},
|
|
315
332
|
"view_resolution": _view_selection_payload(view_selection),
|
|
316
333
|
"fields": fields,
|
|
317
334
|
"suggested_dimensions": suggested_dimensions,
|
|
@@ -531,31 +548,39 @@ class RecordTools(ToolBase):
|
|
|
531
548
|
}
|
|
532
549
|
return response
|
|
533
550
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
"
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
"
|
|
551
|
-
"
|
|
552
|
-
"
|
|
553
|
-
|
|
554
|
-
"
|
|
555
|
-
"
|
|
551
|
+
def runner(session_profile, context):
|
|
552
|
+
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
553
|
+
selected_fields = list(index.by_id.values())
|
|
554
|
+
result = self.backend.request(
|
|
555
|
+
"GET",
|
|
556
|
+
context,
|
|
557
|
+
f"/app/{app_key}/apply/{record_id}",
|
|
558
|
+
params={"role": 1},
|
|
559
|
+
)
|
|
560
|
+
answer_list = result.get("answers") if isinstance(result, dict) and isinstance(result.get("answers"), list) else []
|
|
561
|
+
row = _build_flat_row(cast(list[JSONValue], answer_list), selected_fields, apply_id=record_id)
|
|
562
|
+
response: JSONObject = {
|
|
563
|
+
"profile": profile,
|
|
564
|
+
"ws_id": session_profile.selected_ws_id,
|
|
565
|
+
"ok": True,
|
|
566
|
+
"request_route": self._request_route_payload(context),
|
|
567
|
+
"warnings": [],
|
|
568
|
+
"output_profile": normalized_output_profile,
|
|
569
|
+
"data": {
|
|
570
|
+
"app_key": app_key,
|
|
571
|
+
"record_id": record_id,
|
|
572
|
+
"record": row,
|
|
573
|
+
"selection": {
|
|
574
|
+
"columns": columns,
|
|
575
|
+
"workflow_node_id": workflow_node_id,
|
|
576
|
+
},
|
|
556
577
|
},
|
|
557
|
-
}
|
|
558
|
-
|
|
578
|
+
}
|
|
579
|
+
if normalized_output_profile == "verbose":
|
|
580
|
+
response["data"]["debug"] = {"raw_record": result}
|
|
581
|
+
return response
|
|
582
|
+
|
|
583
|
+
return self._run_record_tool(profile, runner)
|
|
559
584
|
|
|
560
585
|
def record_write(
|
|
561
586
|
self,
|
|
@@ -712,7 +737,7 @@ class RecordTools(ToolBase):
|
|
|
712
737
|
preflight=None,
|
|
713
738
|
)
|
|
714
739
|
|
|
715
|
-
def _schema_field_payload(self, field: FormField) -> JSONObject:
|
|
740
|
+
def _schema_field_payload(self, field: FormField, *, workflow_node_id: int) -> JSONObject:
|
|
716
741
|
write_hints = self._schema_write_hints(field)
|
|
717
742
|
return {
|
|
718
743
|
"field_id": field.que_id,
|
|
@@ -725,6 +750,8 @@ class RecordTools(ToolBase):
|
|
|
725
750
|
"role_hints": self._schema_role_hints(field),
|
|
726
751
|
"readable": True,
|
|
727
752
|
"writable": write_hints["writable"],
|
|
753
|
+
"permission_scope": "applicant_node",
|
|
754
|
+
"workflow_node_id": workflow_node_id,
|
|
728
755
|
"write_kind": write_hints["write_kind"],
|
|
729
756
|
"supported_read_ops": write_hints["supported_read_ops"],
|
|
730
757
|
"supported_write_ops": write_hints["supported_write_ops"],
|
|
@@ -1105,10 +1132,12 @@ class RecordTools(ToolBase):
|
|
|
1105
1132
|
self._ensure_allowed_analyze_keys(
|
|
1106
1133
|
item,
|
|
1107
1134
|
location=f"metrics[{idx}]",
|
|
1108
|
-
allowed_keys={"op", "field_id", "fieldId", "alias"},
|
|
1135
|
+
allowed_keys={"op", "type", "agg", "aggregation", "field_id", "fieldId", "alias"},
|
|
1109
1136
|
example="{'op': 'sum', 'field_id': 7, 'alias': '总金额'}",
|
|
1110
1137
|
)
|
|
1111
|
-
op = _normalize_optional_text(
|
|
1138
|
+
op = _normalize_optional_text(
|
|
1139
|
+
item.get("op") or item.get("type") or item.get("agg") or item.get("aggregation")
|
|
1140
|
+
)
|
|
1112
1141
|
if op not in supported_ops:
|
|
1113
1142
|
raise RecordInputError(
|
|
1114
1143
|
message=f"metrics[{idx}] uses unsupported op '{op}'",
|
|
@@ -1127,12 +1156,8 @@ class RecordTools(ToolBase):
|
|
|
1127
1156
|
details={"location": f"metrics[{idx}]", "field": _field_ref_payload(field), "op": op},
|
|
1128
1157
|
)
|
|
1129
1158
|
elif item.get("field_id", item.get("fieldId")) is not None:
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
error_code="INVALID_ANALYZE_METRIC",
|
|
1133
|
-
fix_hint="Remove field_id from count metrics.",
|
|
1134
|
-
details={"location": f"metrics[{idx}]", "op": op},
|
|
1135
|
-
)
|
|
1159
|
+
# LLM 经常给 count 传 field_id,静默忽略而非报错
|
|
1160
|
+
pass
|
|
1136
1161
|
alias = _normalize_optional_text(item.get("alias"))
|
|
1137
1162
|
if alias is None:
|
|
1138
1163
|
if op == "count":
|
|
@@ -2390,10 +2415,16 @@ class RecordTools(ToolBase):
|
|
|
2390
2415
|
return self._run_record_tool(profile, runner)
|
|
2391
2416
|
|
|
2392
2417
|
def _get_form_schema(self, profile: str, context, app_key: str, *, force_refresh: bool) -> JSONObject: # type: ignore[no-untyped-def]
|
|
2393
|
-
|
|
2418
|
+
applicant_node = self._resolve_applicant_node(profile, context, app_key, force_refresh=force_refresh)
|
|
2419
|
+
cache_key = (profile, app_key, "applicant_node", applicant_node.workflow_node_id)
|
|
2394
2420
|
if not force_refresh and cache_key in self._form_cache:
|
|
2395
2421
|
return self._form_cache[cache_key]
|
|
2396
|
-
schema = self.backend.request(
|
|
2422
|
+
schema = self.backend.request(
|
|
2423
|
+
"GET",
|
|
2424
|
+
context,
|
|
2425
|
+
f"/app/{app_key}/form",
|
|
2426
|
+
params={"type": 1, "beingApply": True, "auditNodeId": applicant_node.workflow_node_id},
|
|
2427
|
+
)
|
|
2397
2428
|
normalized = _normalize_form_schema(schema)
|
|
2398
2429
|
self._form_cache[cache_key] = normalized
|
|
2399
2430
|
return normalized
|
|
@@ -2401,6 +2432,26 @@ class RecordTools(ToolBase):
|
|
|
2401
2432
|
def _get_field_index(self, profile: str, context, app_key: str, *, force_refresh: bool) -> FieldIndex: # type: ignore[no-untyped-def]
|
|
2402
2433
|
return _build_field_index(self._get_form_schema(profile, context, app_key, force_refresh=force_refresh))
|
|
2403
2434
|
|
|
2435
|
+
def _resolve_applicant_node(self, profile: str, context, app_key: str, *, force_refresh: bool) -> WorkflowNodeRef: # type: ignore[no-untyped-def]
|
|
2436
|
+
cache_key = (profile, app_key)
|
|
2437
|
+
if not force_refresh and cache_key in self._applicant_node_cache:
|
|
2438
|
+
return self._applicant_node_cache[cache_key]
|
|
2439
|
+
payload = self.backend.request("GET", context, f"/app/{app_key}/auditNodes")
|
|
2440
|
+
applicant_node = _extract_applicant_node(payload)
|
|
2441
|
+
if applicant_node is None:
|
|
2442
|
+
raise_tool_error(
|
|
2443
|
+
QingflowApiError(
|
|
2444
|
+
category="config",
|
|
2445
|
+
message=f"cannot resolve applicant node for app {app_key}",
|
|
2446
|
+
details={
|
|
2447
|
+
"error_code": "APPLICANT_NODE_NOT_FOUND",
|
|
2448
|
+
"fix_hint": "Ensure the app has a workflow applicant node before using user-side record tools.",
|
|
2449
|
+
},
|
|
2450
|
+
)
|
|
2451
|
+
)
|
|
2452
|
+
self._applicant_node_cache[cache_key] = applicant_node
|
|
2453
|
+
return applicant_node
|
|
2454
|
+
|
|
2404
2455
|
def _get_view_list(self, profile: str, context, app_key: str) -> list[JSONObject]: # type: ignore[no-untyped-def]
|
|
2405
2456
|
cache_key = (profile, app_key)
|
|
2406
2457
|
if cache_key in self._view_list_cache:
|
|
@@ -3883,6 +3934,30 @@ def _normalize_view_list(payload: JSONValue) -> list[JSONObject]:
|
|
|
3883
3934
|
return flattened
|
|
3884
3935
|
|
|
3885
3936
|
|
|
3937
|
+
def _normalize_audit_nodes(payload: JSONValue) -> list[JSONObject]:
|
|
3938
|
+
if isinstance(payload, list):
|
|
3939
|
+
return [item for item in payload if isinstance(item, dict)]
|
|
3940
|
+
if isinstance(payload, dict):
|
|
3941
|
+
return [item for item in payload.values() if isinstance(item, dict)]
|
|
3942
|
+
return []
|
|
3943
|
+
|
|
3944
|
+
|
|
3945
|
+
def _extract_applicant_node(payload: JSONValue) -> WorkflowNodeRef | None:
|
|
3946
|
+
for item in _normalize_audit_nodes(payload):
|
|
3947
|
+
node_type = _coerce_count(item.get("type"))
|
|
3948
|
+
deal_type = _coerce_count(item.get("dealType"))
|
|
3949
|
+
workflow_node_id = _coerce_count(item.get("auditNodeId"))
|
|
3950
|
+
if workflow_node_id is None or node_type != 0 or deal_type != 3:
|
|
3951
|
+
continue
|
|
3952
|
+
return WorkflowNodeRef(
|
|
3953
|
+
workflow_node_id=workflow_node_id,
|
|
3954
|
+
name=_normalize_optional_text(item.get("auditNodeName")) or str(workflow_node_id),
|
|
3955
|
+
type="applicant",
|
|
3956
|
+
raw=item,
|
|
3957
|
+
)
|
|
3958
|
+
return None
|
|
3959
|
+
|
|
3960
|
+
|
|
3886
3961
|
def _compile_view_conditions(config: JSONObject) -> list[list[ViewFilterCondition]]:
|
|
3887
3962
|
raw_limit = config.get("viewgraphLimit")
|
|
3888
3963
|
if not isinstance(raw_limit, list):
|
|
@@ -3919,16 +3994,19 @@ def _build_field_index(schema: JSONObject) -> FieldIndex:
|
|
|
3919
3994
|
*[(question, False) for question in _flatten_questions(schema.get("formQues"))],
|
|
3920
3995
|
]
|
|
3921
3996
|
for question, is_base_question in all_questions:
|
|
3997
|
+
if not _should_index_question(question):
|
|
3998
|
+
continue
|
|
3922
3999
|
que_id = _coerce_count(question.get("queId"))
|
|
3923
4000
|
title = _stringify_json(question.get("queTitle")).strip()
|
|
3924
4001
|
if que_id is None or que_id < 0 or not title:
|
|
3925
4002
|
continue
|
|
4003
|
+
can_edit = question.get("canEdit")
|
|
3926
4004
|
field = FormField(
|
|
3927
4005
|
que_id=que_id,
|
|
3928
4006
|
que_title=title,
|
|
3929
4007
|
que_type=_coerce_count(question.get("queType")),
|
|
3930
4008
|
required=bool(question.get("required") or question.get("beingRequired")),
|
|
3931
|
-
readonly=bool(question.get("readonly") or question.get("beingReadonly") or is_base_question),
|
|
4009
|
+
readonly=bool(question.get("readonly") or question.get("beingReadonly") or is_base_question or can_edit is False),
|
|
3932
4010
|
system=bool(question.get("system") or question.get("beingSystem") or is_base_question),
|
|
3933
4011
|
options=_extract_question_options(question),
|
|
3934
4012
|
aliases=[],
|
|
@@ -3947,16 +4025,35 @@ def _build_field_index(schema: JSONObject) -> FieldIndex:
|
|
|
3947
4025
|
def _flatten_questions(payload: JSONValue) -> list[JSONObject]:
|
|
3948
4026
|
flattened: list[JSONObject] = []
|
|
3949
4027
|
if isinstance(payload, dict):
|
|
3950
|
-
|
|
4028
|
+
is_question = "queId" in payload or "queTitle" in payload
|
|
4029
|
+
if is_question:
|
|
3951
4030
|
flattened.append(payload)
|
|
3952
|
-
for
|
|
3953
|
-
|
|
4031
|
+
for key in ("subQuestions", "innerQuestions", "subQues"):
|
|
4032
|
+
value = payload.get(key)
|
|
4033
|
+
if isinstance(value, list):
|
|
4034
|
+
flattened.extend(_flatten_questions(value))
|
|
4035
|
+
if not is_question:
|
|
4036
|
+
for key in ("baseQues", "formQues"):
|
|
4037
|
+
value = payload.get(key)
|
|
4038
|
+
if isinstance(value, list):
|
|
4039
|
+
flattened.extend(_flatten_questions(value))
|
|
3954
4040
|
elif isinstance(payload, list):
|
|
3955
4041
|
for item in payload:
|
|
3956
4042
|
flattened.extend(_flatten_questions(item))
|
|
3957
4043
|
return flattened
|
|
3958
4044
|
|
|
3959
4045
|
|
|
4046
|
+
def _should_index_question(question: JSONObject) -> bool:
|
|
4047
|
+
if bool(question.get("beingHide") or question.get("hidden")):
|
|
4048
|
+
return False
|
|
4049
|
+
if _coerce_count(question.get("quoteId")) is not None:
|
|
4050
|
+
return False
|
|
4051
|
+
que_type = _coerce_count(question.get("queType"))
|
|
4052
|
+
if que_type in LAYOUT_ONLY_QUE_TYPES:
|
|
4053
|
+
return False
|
|
4054
|
+
return True
|
|
4055
|
+
|
|
4056
|
+
|
|
3960
4057
|
def _extract_question_options(question: JSONObject) -> list[str]:
|
|
3961
4058
|
options = question.get("options")
|
|
3962
4059
|
if not isinstance(options, list):
|