@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 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.49
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.49 qingflow-app-user-mcp
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-user-mcp",
3
- "version": "0.2.0-beta.49",
3
+ "version": "0.2.0-beta.50",
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.0b49"
7
+ version = "0.2.0b50"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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. Write records: `record_schema_get(schema_mode="applicant") -> record_write`
22
- 4. Run a code-block field: `record_schema_get(schema_mode="applicant") -> record_code_block_run`
23
- 5. Import records: `record_import_template_get -> record_import_verify -> (optional authorized file repair) -> record_import_start -> record_import_status_get`
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. If fields are still ambiguous after `record_schema_get`, ask the user to confirm from a short candidate list instead of guessing
72
- 13. For high-risk writes or production changes, read the current state first whenever practical
73
- 14. After actions, report the affected `record_id`, counts, or returned item count
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. For relation fields, read `target_app_key / target_app_name` from schema first
95
- 4. For member fields with unknown ids, run `record_member_candidates`
96
- 5. For department fields with unknown ids, run `record_department_candidates`
97
- 6. Build SQL-like JSON clauses
98
- 7. Run `record_write`
99
- 8. If `ok=false`, explain `field_errors` first, then summarize blockers; do not report a write as executed
100
- 9. If `ok=true`, report the affected `record_id` or created resource
101
- 10. For important writes, keep `verify_write=true`
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 visible_fields are only allowed for relation fields")
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 visible_fields are only allowed for relation fields")
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