@josephyan/qingflow-app-user-mcp 0.2.0-beta.49 → 0.2.0-beta.50
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-app-user/SKILL.md +4 -0
- package/skills/qingflow-record-crud/SKILL.md +35 -14
- package/src/qingflow_mcp/builder_facade/models.py +61 -5
- package/src/qingflow_mcp/builder_facade/service.py +525 -20
- package/src/qingflow_mcp/tools/ai_builder_tools.py +51 -0
- package/src/qingflow_mcp/tools/app_tools.py +36 -0
- package/src/qingflow_mcp/tools/code_block_tools.py +18 -3
- package/src/qingflow_mcp/tools/record_tools.py +735 -90
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.50
|
|
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.50 qingflow-app-user-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -34,6 +34,10 @@ Route to exactly one of these specialized paths:
|
|
|
34
34
|
- If the app is known but the available data range is unclear, call `app_get` first and inspect `accessible_views`
|
|
35
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
|
+
- If the task is about creating new records or importing data, prefer `$qingflow-record-crud` under applicant-node schema semantics
|
|
38
|
+
- If the task is about updating an existing record directly, require an explicit accessible `view_id` and then route to `$qingflow-record-crud`
|
|
39
|
+
- If the task is about subtable writes, still route to `$qingflow-record-crud`, but shape the payload through the parent subtable field `rows/tableValues`; do not route users toward top-level leaf selectors
|
|
40
|
+
- If the user sounds like an ordinary workflow assignee rather than a system operator, prefer `$qingflow-task-ops` over direct record mutation whenever both paths could fit
|
|
37
41
|
- If the task is about grouped distributions, ratios, rankings, trends, insights, or any final statistical conclusion, switch to `$qingflow-record-analysis`
|
|
38
42
|
- If the MCP is not connected, authenticated, or bound to the right workspace, switch to `$qingflow-mcp-setup`
|
|
39
43
|
|
|
@@ -18,9 +18,10 @@ Use exactly one of these default paths:
|
|
|
18
18
|
|
|
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
|
-
3.
|
|
22
|
-
4.
|
|
23
|
-
5.
|
|
21
|
+
3. Insert records: `record_schema_get(schema_mode="applicant") -> record_write(operation="insert")`
|
|
22
|
+
4. Update records: `app_get -> choose accessible view -> record_schema_get(schema_mode="browse", view_id=...) -> record_write(operation="update", view_id=...)`
|
|
23
|
+
5. Run a code-block field: `record_schema_get(schema_mode="applicant") -> record_code_block_run`
|
|
24
|
+
6. Import records: `record_import_template_get -> record_import_verify -> (optional authorized file repair) -> record_import_start -> record_import_status_get`
|
|
24
25
|
|
|
25
26
|
## Core Tools
|
|
26
27
|
|
|
@@ -36,7 +37,9 @@ Use exactly one of these default paths:
|
|
|
36
37
|
- `record_import_status_get`
|
|
37
38
|
|
|
38
39
|
`record_schema_get(schema_mode="applicant")` exposes the current user's applicant-node visible write/create fields.
|
|
40
|
+
Its top-level `fields` now list only direct applicant-write fields; subtable leaf columns stay under the parent subtable field's `write_format.subfields`.
|
|
39
41
|
`record_schema_get(schema_mode="browse", view_id=...)` exposes browse-schema fields for the selected accessible view.
|
|
42
|
+
In browse mode, `writable=true` means `record_write(operation="update", view_id=...)` can target that field in the selected view; `writable=false` means the chosen view blocks direct update for that field.
|
|
40
43
|
Read top-level `fields` and `suggested_*`; if a field is missing, treat it as unavailable in the current permission scope.
|
|
41
44
|
|
|
42
45
|
## Supporting Tools
|
|
@@ -68,9 +71,13 @@ Use `record_member_candidates` / `record_department_candidates` as the default l
|
|
|
68
71
|
9. If the request is code-block-like, inspect applicant schema first and confirm the exact `code_block_field`
|
|
69
72
|
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
73
|
11. If the request is write-like, decide `insert / update / delete` before building any payload
|
|
71
|
-
12.
|
|
72
|
-
13. For
|
|
73
|
-
14.
|
|
74
|
+
12. For `insert`, stay on `schema_mode="applicant"` and do not introduce any view selector
|
|
75
|
+
13. For `update`, call `app_get` first, choose an accessible view, and carry that `view_id` into `record_write`
|
|
76
|
+
14. For subtable writes, use the parent subtable field and submit `rows/tableValues`; do not treat subtable leaf titles as top-level write selectors
|
|
77
|
+
15. If fields are still ambiguous after `record_schema_get`, ask the user to confirm from a short candidate list instead of guessing
|
|
78
|
+
16. For high-risk writes or production changes, read the current state first whenever practical
|
|
79
|
+
17. After actions, report the affected `record_id`, counts, or returned item count
|
|
80
|
+
18. If the user is an approver, filler, or ordinary workflow assignee rather than a system operator, prefer [$qingflow-task-ops](/Users/yanqidong/Documents/qingflow-next/.codex/skills/qingflow-task-ops/SKILL.md) over direct record mutation
|
|
74
81
|
|
|
75
82
|
## Record Read Rules
|
|
76
83
|
|
|
@@ -91,14 +98,18 @@ Use `record_write` as the only default write tool.
|
|
|
91
98
|
|
|
92
99
|
1. Run `record_schema_get(schema_mode="applicant")`
|
|
93
100
|
2. Decide whether the task is `insert`, `update`, or `delete`
|
|
94
|
-
3.
|
|
95
|
-
4.
|
|
96
|
-
5.
|
|
97
|
-
6.
|
|
98
|
-
7.
|
|
99
|
-
8.
|
|
100
|
-
9.
|
|
101
|
-
10.
|
|
101
|
+
3. If this is workflow work for an ordinary assignee, switch to [$qingflow-task-ops](/Users/yanqidong/Documents/qingflow-next/.codex/skills/qingflow-task-ops/SKILL.md) instead of forcing direct CRUD
|
|
102
|
+
4. If this is `insert`, stay on applicant schema only and do not pass any `view_*` selector
|
|
103
|
+
5. If this is `update`, choose an accessible view first, inspect `record_schema_get(schema_mode="browse", view_id=...)`, and pass that `view_id` into `record_write`
|
|
104
|
+
6. For relation fields, read `target_app_key / target_app_name` from schema first
|
|
105
|
+
7. For member fields with unknown ids, run `record_member_candidates`
|
|
106
|
+
8. For department fields with unknown ids, run `record_department_candidates`
|
|
107
|
+
9. For subtable writes, target the parent subtable field and fill `rows/tableValues`; subtable leaf titles are not valid top-level `record_write` selectors
|
|
108
|
+
10. Build SQL-like JSON clauses
|
|
109
|
+
11. Run `record_write`
|
|
110
|
+
12. If `ok=false`, explain `field_errors` first, then summarize blockers; do not report a write as executed
|
|
111
|
+
13. If `ok=true`, report the affected `record_id` or created resource
|
|
112
|
+
14. For important writes, keep `verify_write=true`
|
|
102
113
|
|
|
103
114
|
### SQL-like JSON DSL
|
|
104
115
|
|
|
@@ -115,6 +126,11 @@ The DSL is clause-shaped like SQL, but it is **not raw SQL text**.
|
|
|
115
126
|
- `insert` uses `values`
|
|
116
127
|
- `update` uses `set`
|
|
117
128
|
- `delete` uses `record_id` or `record_ids`
|
|
129
|
+
- `insert` strictly follows applicant-node write scope and does not accept `view_id / list_type / view_key / view_name`
|
|
130
|
+
- `update` must carry an explicit `view_id / list_type / view_key / view_name`; prefer `view_id` chosen from `app_get.accessible_views`
|
|
131
|
+
- `update` should be treated as “view-scoped direct edit”, not as a task or approval shortcut
|
|
132
|
+
- `record_schema_get(schema_mode="browse").fields[*].writable` is the contract for whether the selected view allows direct update on that field
|
|
133
|
+
- subtable writes must go through the parent subtable field with `rows/tableValues`; top-level subtable leaf selectors now fail fast
|
|
118
134
|
- Do not send raw SQL text
|
|
119
135
|
- Do not invent formulas or expressions
|
|
120
136
|
- Do not use free-form `WHERE` updates or deletes
|
|
@@ -133,6 +149,7 @@ The DSL is clause-shaped like SQL, but it is **not raw SQL text**.
|
|
|
133
149
|
- `department`: `✅ {"field_id":22,"value":{"id":336193,"value":"北斗组"}}` / `✅ {"field_id":22,"value":"北斗组"}` only when it exactly matches a current candidate / `❌ {"field_id":22,"value":"不存在的部门"}`
|
|
134
150
|
- `relation`: `✅ {"field_id":25,"value":{"apply_id":5001}}` / `❌ {"field_id":25,"value":"客户A"}`
|
|
135
151
|
- `attachment`: upload first, then `✅ {"field_id":13,"value":{"value":"https://.../a.pdf","name":"a.pdf"}}` / `❌ {"field_id":13,"value":"/tmp/a.pdf"}`
|
|
152
|
+
- `subtable`: `✅ {"field_id":18,"value":{"rows":[{"产品名称":"企业版","数量":2}]}}` / `✅ {"field_id":18,"value":{"tableValues":[{"queId":101,"values":[{"value":"企业版"}]},{"queId":102,"values":[{"value":2}]}]}}` / `❌ {"field_id":101,"value":"企业版"}`
|
|
136
153
|
|
|
137
154
|
## Code Block Rules
|
|
138
155
|
|
|
@@ -199,7 +216,11 @@ Use the import tools for file-based bulk data loading, not `record_write`.
|
|
|
199
216
|
- If `view_id` is a custom view, treat the result as convenience browse output only when `warnings` includes `CUSTOM_VIEW_FILTER_UNVERIFIED`. In that case do not state the saved filter result as a verified fact.
|
|
200
217
|
- Prefer `system:all` plus explicit `where` filters whenever the user needs a trustworthy scoped dataset.
|
|
201
218
|
- `record_write` always performs internal static preflight before any apply
|
|
219
|
+
- `record_write insert` is validated strictly against applicant-node create scope
|
|
220
|
+
- `record_write update` is validated against the selected view for field scope, then against real record edit permission
|
|
221
|
+
- if a subtable leaf is missing from applicant top-level schema, that is expected; inspect the parent subtable field's `write_format.subfields`
|
|
202
222
|
- If `record_write` returns `ok=false`, the write was blocked and not executed
|
|
223
|
+
- If `record_write` raises `WRITE_PERMISSION_DENIED`, explain that direct edit permission is missing and prefer task-center actions for ordinary workflow users
|
|
203
224
|
- Prefer explaining `field_errors` before summarizing top-level blockers
|
|
204
225
|
- If `record_write` returns `ok=true`, still check `verification` and `warnings` before claiming success
|
|
205
226
|
- Prefer canonical schema titles and aliases in your final wording
|
|
@@ -28,6 +28,11 @@ class PublicFieldType(str, Enum):
|
|
|
28
28
|
subtable = "subtable"
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
class PublicRelationMode(str, Enum):
|
|
32
|
+
single = "single"
|
|
33
|
+
multiple = "multiple"
|
|
34
|
+
|
|
35
|
+
|
|
31
36
|
FIELD_TYPE_ALIASES: dict[str, PublicFieldType] = {
|
|
32
37
|
"textarea": PublicFieldType.long_text,
|
|
33
38
|
"amount": PublicFieldType.amount,
|
|
@@ -268,6 +273,7 @@ class FieldPatch(StrictModel):
|
|
|
268
273
|
target_app_key: str | None = None
|
|
269
274
|
display_field: FieldSelector | None = None
|
|
270
275
|
visible_fields: list[FieldSelector] = Field(default_factory=list)
|
|
276
|
+
relation_mode: PublicRelationMode | None = Field(default=None, validation_alias=AliasChoices("relation_mode", "relationMode", "selection_mode", "selectionMode"))
|
|
271
277
|
subfields: list["FieldPatch"] = Field(default_factory=list)
|
|
272
278
|
|
|
273
279
|
@model_validator(mode="after")
|
|
@@ -276,8 +282,8 @@ class FieldPatch(StrictModel):
|
|
|
276
282
|
raise ValueError("relation field requires target_app_key")
|
|
277
283
|
if self.type != PublicFieldType.relation and self.target_app_key:
|
|
278
284
|
raise ValueError("target_app_key is only allowed for relation fields")
|
|
279
|
-
if self.type != PublicFieldType.relation and (self.display_field is not None or self.visible_fields):
|
|
280
|
-
raise ValueError("display_field and
|
|
285
|
+
if self.type != PublicFieldType.relation and (self.display_field is not None or self.visible_fields or self.relation_mode is not None):
|
|
286
|
+
raise ValueError("display_field, visible_fields, and relation_mode are only allowed for relation fields")
|
|
281
287
|
if self.type == PublicFieldType.subtable and not self.subfields:
|
|
282
288
|
raise ValueError("subtable field requires subfields")
|
|
283
289
|
if self.type != PublicFieldType.subtable and self.subfields:
|
|
@@ -299,14 +305,15 @@ class FieldMutation(StrictModel):
|
|
|
299
305
|
target_app_key: str | None = None
|
|
300
306
|
display_field: FieldSelector | None = None
|
|
301
307
|
visible_fields: list[FieldSelector] | None = None
|
|
308
|
+
relation_mode: PublicRelationMode | None = Field(default=None, validation_alias=AliasChoices("relation_mode", "relationMode", "selection_mode", "selectionMode"))
|
|
302
309
|
subfields: list[FieldPatch] | None = None
|
|
303
310
|
|
|
304
311
|
@model_validator(mode="after")
|
|
305
312
|
def validate_shape(self) -> "FieldMutation":
|
|
306
313
|
if self.type == PublicFieldType.relation and not self.target_app_key:
|
|
307
314
|
raise ValueError("relation field requires target_app_key")
|
|
308
|
-
if self.type is not None and self.type != PublicFieldType.relation and (self.display_field is not None or self.visible_fields):
|
|
309
|
-
raise ValueError("display_field and
|
|
315
|
+
if self.type is not None and self.type != PublicFieldType.relation and (self.display_field is not None or self.visible_fields or self.relation_mode is not None):
|
|
316
|
+
raise ValueError("display_field, visible_fields, and relation_mode are only allowed for relation fields")
|
|
310
317
|
if self.type == PublicFieldType.subtable and not self.subfields:
|
|
311
318
|
raise ValueError("subtable field requires subfields")
|
|
312
319
|
return self
|
|
@@ -915,11 +922,30 @@ def _normalize_field_payload(value: Any) -> Any:
|
|
|
915
922
|
normalized_from_id = FIELD_TYPE_ID_ALIASES.get(raw_type)
|
|
916
923
|
if normalized_from_id is not None:
|
|
917
924
|
payload["type"] = normalized_from_id.value
|
|
918
|
-
return payload
|
|
919
925
|
if isinstance(raw_type, str):
|
|
920
926
|
normalized = FIELD_TYPE_ALIASES.get(raw_type.strip().lower())
|
|
921
927
|
if normalized is not None:
|
|
922
928
|
payload["type"] = normalized.value
|
|
929
|
+
normalized_relation_mode = _normalize_public_relation_mode(
|
|
930
|
+
payload.get("relation_mode", payload.get("relationMode", payload.get("selection_mode", payload.get("selectionMode"))))
|
|
931
|
+
)
|
|
932
|
+
if normalized_relation_mode is None:
|
|
933
|
+
for alias_key in ("optional_data_num", "optionalDataNum", "multiple", "allow_multiple"):
|
|
934
|
+
if alias_key in payload:
|
|
935
|
+
normalized_relation_mode = _normalize_public_relation_mode(payload.get(alias_key))
|
|
936
|
+
break
|
|
937
|
+
if normalized_relation_mode is not None:
|
|
938
|
+
payload["relation_mode"] = normalized_relation_mode
|
|
939
|
+
for alias_key in (
|
|
940
|
+
"relationMode",
|
|
941
|
+
"selection_mode",
|
|
942
|
+
"selectionMode",
|
|
943
|
+
"optional_data_num",
|
|
944
|
+
"optionalDataNum",
|
|
945
|
+
"multiple",
|
|
946
|
+
"allow_multiple",
|
|
947
|
+
):
|
|
948
|
+
payload.pop(alias_key, None)
|
|
923
949
|
return payload
|
|
924
950
|
|
|
925
951
|
|
|
@@ -927,3 +953,33 @@ def _slugify_title(title: str) -> str:
|
|
|
927
953
|
normalized = "".join(ch.lower() if ch.isalnum() else "_" for ch in str(title or ""))
|
|
928
954
|
collapsed = "_".join(part for part in normalized.split("_") if part)
|
|
929
955
|
return collapsed or "section"
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _normalize_public_relation_mode(value: Any) -> str | None:
|
|
959
|
+
if value is None:
|
|
960
|
+
return None
|
|
961
|
+
if isinstance(value, bool):
|
|
962
|
+
return PublicRelationMode.multiple.value if value else PublicRelationMode.single.value
|
|
963
|
+
if isinstance(value, int):
|
|
964
|
+
if value == 0:
|
|
965
|
+
return PublicRelationMode.multiple.value
|
|
966
|
+
if value == 1:
|
|
967
|
+
return PublicRelationMode.single.value
|
|
968
|
+
return None
|
|
969
|
+
if isinstance(value, str):
|
|
970
|
+
normalized = value.strip().lower()
|
|
971
|
+
aliases = {
|
|
972
|
+
"single": PublicRelationMode.single.value,
|
|
973
|
+
"single_select": PublicRelationMode.single.value,
|
|
974
|
+
"single-select": PublicRelationMode.single.value,
|
|
975
|
+
"one": PublicRelationMode.single.value,
|
|
976
|
+
"1": PublicRelationMode.single.value,
|
|
977
|
+
"multiple": PublicRelationMode.multiple.value,
|
|
978
|
+
"multi": PublicRelationMode.multiple.value,
|
|
979
|
+
"multi_select": PublicRelationMode.multiple.value,
|
|
980
|
+
"multi-select": PublicRelationMode.multiple.value,
|
|
981
|
+
"many": PublicRelationMode.multiple.value,
|
|
982
|
+
"0": PublicRelationMode.multiple.value,
|
|
983
|
+
}
|
|
984
|
+
return aliases.get(normalized, normalized or None)
|
|
985
|
+
return None
|