@josephyan/qingflow-app-user-mcp 0.2.0-beta.2 → 0.2.0-beta.21

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.
Files changed (32) hide show
  1. package/README.md +12 -2
  2. package/npm/lib/runtime.mjs +37 -0
  3. package/npm/scripts/postinstall.mjs +5 -1
  4. package/package.json +3 -2
  5. package/pyproject.toml +1 -1
  6. package/skills/qingflow-app-user/SKILL.md +230 -0
  7. package/skills/qingflow-app-user/agents/openai.yaml +4 -0
  8. package/skills/qingflow-app-user/references/data-gotchas.md +49 -0
  9. package/skills/qingflow-app-user/references/environments.md +63 -0
  10. package/skills/qingflow-app-user/references/record-patterns.md +110 -0
  11. package/skills/qingflow-app-user/references/workflow-usage.md +26 -0
  12. package/skills/qingflow-record-analysis/SKILL.md +253 -0
  13. package/skills/qingflow-record-analysis/agents/openai.yaml +4 -0
  14. package/skills/qingflow-record-analysis/references/analysis-gotchas.md +141 -0
  15. package/skills/qingflow-record-analysis/references/analysis-patterns.md +113 -0
  16. package/skills/qingflow-record-analysis/references/confidence-reporting.md +92 -0
  17. package/src/qingflow_mcp/__init__.py +1 -1
  18. package/src/qingflow_mcp/builder_facade/models.py +294 -1
  19. package/src/qingflow_mcp/builder_facade/service.py +2727 -235
  20. package/src/qingflow_mcp/server.py +7 -5
  21. package/src/qingflow_mcp/server_app_builder.py +80 -4
  22. package/src/qingflow_mcp/server_app_user.py +8 -182
  23. package/src/qingflow_mcp/solution/compiler/form_compiler.py +1 -1
  24. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +21 -2
  25. package/src/qingflow_mcp/solution/executor.py +34 -7
  26. package/src/qingflow_mcp/tools/ai_builder_tools.py +1038 -30
  27. package/src/qingflow_mcp/tools/app_tools.py +1 -2
  28. package/src/qingflow_mcp/tools/approval_tools.py +357 -75
  29. package/src/qingflow_mcp/tools/directory_tools.py +158 -28
  30. package/src/qingflow_mcp/tools/record_tools.py +1954 -973
  31. package/src/qingflow_mcp/tools/task_tools.py +376 -225
  32. package/src/qingflow_mcp/tools/workflow_tools.py +78 -4
@@ -43,11 +43,30 @@ FIELD_TYPE_ALIASES: dict[str, PublicFieldType] = {
43
43
  "departments": PublicFieldType.department,
44
44
  }
45
45
 
46
+ FIELD_TYPE_ID_ALIASES: dict[int, PublicFieldType] = {
47
+ 2: PublicFieldType.text,
48
+ 3: PublicFieldType.long_text,
49
+ 4: PublicFieldType.date,
50
+ 5: PublicFieldType.member,
51
+ 6: PublicFieldType.email,
52
+ 7: PublicFieldType.phone,
53
+ 8: PublicFieldType.number,
54
+ 10: PublicFieldType.boolean,
55
+ 11: PublicFieldType.single_select,
56
+ 12: PublicFieldType.multi_select,
57
+ 13: PublicFieldType.attachment,
58
+ 18: PublicFieldType.subtable,
59
+ 21: PublicFieldType.address,
60
+ 22: PublicFieldType.department,
61
+ 25: PublicFieldType.relation,
62
+ }
63
+
46
64
 
47
65
  class PublicViewType(str, Enum):
48
66
  table = "table"
49
67
  card = "card"
50
68
  board = "board"
69
+ gantt = "gantt"
51
70
 
52
71
 
53
72
  class LayoutApplyMode(str, Enum):
@@ -69,6 +88,7 @@ class FlowPreset(str, Enum):
69
88
  class ViewsPreset(str, Enum):
70
89
  default_table = "default_table"
71
90
  status_board = "status_board"
91
+ default_gantt = "default_gantt"
72
92
 
73
93
 
74
94
  class PublicFlowNodeType(str, Enum):
@@ -82,6 +102,143 @@ class PublicFlowNodeType(str, Enum):
82
102
  end = "end"
83
103
 
84
104
 
105
+ class FlowConditionOperator(str, Enum):
106
+ eq = "eq"
107
+ neq = "neq"
108
+ in_ = "in"
109
+ contains = "contains"
110
+ gte = "gte"
111
+ lte = "lte"
112
+ is_empty = "is_empty"
113
+ not_empty = "not_empty"
114
+
115
+
116
+ class ViewFilterOperator(str, Enum):
117
+ eq = "eq"
118
+ neq = "neq"
119
+ in_ = "in"
120
+ contains = "contains"
121
+ gte = "gte"
122
+ lte = "lte"
123
+ is_empty = "is_empty"
124
+ not_empty = "not_empty"
125
+
126
+
127
+ class FlowAssigneePatch(StrictModel):
128
+ role_ids: list[int] = Field(default_factory=list)
129
+ role_names: list[str] = Field(default_factory=list)
130
+ member_uids: list[int] = Field(default_factory=list)
131
+ member_emails: list[str] = Field(default_factory=list)
132
+ member_names: list[str] = Field(default_factory=list)
133
+ include_sub_departs: bool | None = None
134
+
135
+
136
+ class FlowNodePermissionsPatch(StrictModel):
137
+ editable_fields: list[str] = Field(default_factory=list)
138
+
139
+
140
+ class FlowConditionRulePatch(StrictModel):
141
+ field_name: str = Field(validation_alias=AliasChoices("field_name", "fieldName", "field", "name"))
142
+ operator: FlowConditionOperator = Field(validation_alias=AliasChoices("operator", "op"))
143
+ values: list[Any] = Field(default_factory=list)
144
+
145
+ @model_validator(mode="before")
146
+ @classmethod
147
+ def normalize_aliases(cls, value: Any) -> Any:
148
+ if not isinstance(value, dict):
149
+ return value
150
+ payload = dict(value)
151
+ if "value" in payload and "values" not in payload:
152
+ payload["values"] = [payload.pop("value")]
153
+ raw_operator = payload.get("operator", payload.get("op"))
154
+ if isinstance(raw_operator, str):
155
+ normalized = raw_operator.strip().lower()
156
+ operator_aliases = {
157
+ "equals": FlowConditionOperator.eq.value,
158
+ "equal": FlowConditionOperator.eq.value,
159
+ "=": FlowConditionOperator.eq.value,
160
+ "not_equals": FlowConditionOperator.neq.value,
161
+ "not_equal": FlowConditionOperator.neq.value,
162
+ "!=": FlowConditionOperator.neq.value,
163
+ ">=": FlowConditionOperator.gte.value,
164
+ "<=": FlowConditionOperator.lte.value,
165
+ "any_of": FlowConditionOperator.in_.value,
166
+ "one_of": FlowConditionOperator.in_.value,
167
+ "between_any": FlowConditionOperator.in_.value,
168
+ "empty": FlowConditionOperator.is_empty.value,
169
+ "is blank": FlowConditionOperator.is_empty.value,
170
+ "blank": FlowConditionOperator.is_empty.value,
171
+ "not_empty": FlowConditionOperator.not_empty.value,
172
+ "not blank": FlowConditionOperator.not_empty.value,
173
+ }
174
+ if normalized in operator_aliases:
175
+ payload["operator"] = operator_aliases[normalized]
176
+ elif "operator" not in payload:
177
+ payload["operator"] = normalized
178
+ payload.pop("op", None)
179
+ return payload
180
+
181
+ @model_validator(mode="after")
182
+ def validate_shape(self) -> "FlowConditionRulePatch":
183
+ if self.operator in {FlowConditionOperator.is_empty, FlowConditionOperator.not_empty}:
184
+ self.values = []
185
+ return self
186
+ if not self.values:
187
+ raise ValueError("condition rule requires values")
188
+ return self
189
+
190
+
191
+ class ViewFilterRulePatch(StrictModel):
192
+ field_name: str = Field(validation_alias=AliasChoices("field_name", "fieldName", "field", "name"))
193
+ operator: ViewFilterOperator = Field(validation_alias=AliasChoices("operator", "op"))
194
+ values: list[Any] = Field(default_factory=list)
195
+
196
+ @model_validator(mode="before")
197
+ @classmethod
198
+ def normalize_aliases(cls, value: Any) -> Any:
199
+ if not isinstance(value, dict):
200
+ return value
201
+ payload = dict(value)
202
+ if "value" in payload and "values" not in payload:
203
+ payload["values"] = [payload.pop("value")]
204
+ raw_operator = payload.get("operator", payload.get("op"))
205
+ if isinstance(raw_operator, str):
206
+ normalized = raw_operator.strip().lower()
207
+ operator_aliases = {
208
+ "equals": ViewFilterOperator.eq.value,
209
+ "equal": ViewFilterOperator.eq.value,
210
+ "=": ViewFilterOperator.eq.value,
211
+ "not_equals": ViewFilterOperator.neq.value,
212
+ "not_equal": ViewFilterOperator.neq.value,
213
+ "!=": ViewFilterOperator.neq.value,
214
+ ">=": ViewFilterOperator.gte.value,
215
+ "<=": ViewFilterOperator.lte.value,
216
+ "any_of": ViewFilterOperator.in_.value,
217
+ "one_of": ViewFilterOperator.in_.value,
218
+ "between_any": ViewFilterOperator.in_.value,
219
+ "empty": ViewFilterOperator.is_empty.value,
220
+ "is blank": ViewFilterOperator.is_empty.value,
221
+ "blank": ViewFilterOperator.is_empty.value,
222
+ "not_empty": ViewFilterOperator.not_empty.value,
223
+ "not blank": ViewFilterOperator.not_empty.value,
224
+ }
225
+ if normalized in operator_aliases:
226
+ payload["operator"] = operator_aliases[normalized]
227
+ elif "operator" not in payload:
228
+ payload["operator"] = normalized
229
+ payload.pop("op", None)
230
+ return payload
231
+
232
+ @model_validator(mode="after")
233
+ def validate_shape(self) -> "ViewFilterRulePatch":
234
+ if self.operator in {ViewFilterOperator.is_empty, ViewFilterOperator.not_empty}:
235
+ self.values = []
236
+ return self
237
+ if not self.values:
238
+ raise ValueError("view filter rule requires values")
239
+ return self
240
+
241
+
85
242
  class FieldSelector(StrictModel):
86
243
  field_id: str | None = None
87
244
  que_id: int | None = None
@@ -161,15 +318,65 @@ class FieldRemovePatch(StrictModel):
161
318
  return self
162
319
 
163
320
 
321
+ def _coerce_layout_columns(value: Any) -> int | None:
322
+ if isinstance(value, bool):
323
+ return None
324
+ if isinstance(value, int):
325
+ return value if value > 0 else None
326
+ if isinstance(value, str):
327
+ stripped = value.strip()
328
+ if stripped.isdigit():
329
+ parsed = int(stripped)
330
+ return parsed if parsed > 0 else None
331
+ return None
332
+
333
+
334
+ def _normalize_layout_rows(value: Any, *, columns: int | None = None) -> Any:
335
+ if not isinstance(value, list):
336
+ return value
337
+ if value and all(isinstance(item, list) for item in value):
338
+ return value
339
+ if not value:
340
+ return []
341
+ width = columns if columns and columns > 0 else None
342
+ if width is None:
343
+ return [list(value)]
344
+ return [list(value[index : index + width]) for index in range(0, len(value), width) if value[index : index + width]]
345
+
346
+
164
347
  class LayoutSectionPatch(StrictModel):
165
348
  section_id: str | None = Field(default=None, validation_alias=AliasChoices("section_id", "sectionId"))
166
349
  title: str
167
- rows: list[list[str]] = Field(default_factory=list)
350
+ rows: list[list[Any]] = Field(default_factory=list)
351
+
352
+ @model_validator(mode="before")
353
+ @classmethod
354
+ def normalize_aliases(cls, value: Any) -> Any:
355
+ if not isinstance(value, dict):
356
+ return value
357
+ payload = dict(value)
358
+ if "name" in payload and "title" not in payload:
359
+ payload["title"] = payload.pop("name")
360
+ shorthand: Any | None = None
361
+ if "rows" not in payload:
362
+ if "fields" in payload:
363
+ shorthand = payload.pop("fields")
364
+ elif "field_ids" in payload:
365
+ shorthand = payload.pop("field_ids")
366
+ if shorthand is not None:
367
+ payload["rows"] = _normalize_layout_rows(
368
+ shorthand,
369
+ columns=_coerce_layout_columns(payload.pop("columns", None)),
370
+ )
371
+ return payload
168
372
 
169
373
  @model_validator(mode="after")
170
374
  def validate_rows(self) -> "LayoutSectionPatch":
171
375
  if not self.rows:
172
376
  raise ValueError("section rows must be a non-empty list")
377
+ for row in self.rows:
378
+ if not isinstance(row, list) or not row:
379
+ raise ValueError("section rows must be a non-empty list")
173
380
  if not self.section_id:
174
381
  self.section_id = _slugify_title(self.title)
175
382
  return self
@@ -179,8 +386,54 @@ class FlowNodePatch(StrictModel):
179
386
  id: str
180
387
  type: PublicFlowNodeType
181
388
  name: str
389
+ assignees: FlowAssigneePatch = Field(default_factory=FlowAssigneePatch)
390
+ permissions: FlowNodePermissionsPatch = Field(default_factory=FlowNodePermissionsPatch)
391
+ conditions: list[FlowConditionRulePatch] = Field(default_factory=list)
392
+ condition_groups: list[list[FlowConditionRulePatch]] = Field(default_factory=list)
182
393
  config: dict[str, Any] = Field(default_factory=dict)
183
394
 
395
+ @model_validator(mode="before")
396
+ @classmethod
397
+ def normalize_aliases(cls, value: Any) -> Any:
398
+ if not isinstance(value, dict):
399
+ return value
400
+ payload = dict(value)
401
+ assignees = dict(payload.get("assignees") or {})
402
+ permissions = dict(payload.get("permissions") or {})
403
+
404
+ for key in ("role_ids", "role_names", "member_uids", "member_emails", "member_names", "include_sub_departs"):
405
+ if key in payload and key not in assignees:
406
+ assignees[key] = payload.pop(key)
407
+ for key in ("editable_fields",):
408
+ if key in payload and key not in permissions:
409
+ permissions[key] = payload.pop(key)
410
+ if "filters" in payload and "conditions" not in payload:
411
+ payload["conditions"] = payload.pop("filters")
412
+ if "rules" in payload and "conditions" not in payload:
413
+ payload["conditions"] = payload.pop("rules")
414
+ if "conditionRules" in payload and "condition_groups" not in payload:
415
+ payload["condition_groups"] = payload.pop("conditionRules")
416
+ if "conditionGroups" in payload and "condition_groups" not in payload:
417
+ payload["condition_groups"] = payload.pop("conditionGroups")
418
+ if "owners" in payload and "member_names" not in assignees:
419
+ assignees["member_names"] = payload.pop("owners")
420
+ if "approvers" in payload and "role_names" not in assignees:
421
+ assignees["role_names"] = payload.pop("approvers")
422
+ if assignees:
423
+ payload["assignees"] = assignees
424
+ if permissions:
425
+ payload["permissions"] = permissions
426
+ return payload
427
+
428
+ @model_validator(mode="after")
429
+ def validate_branch_conditions(self) -> "FlowNodePatch":
430
+ if self.conditions:
431
+ self.condition_groups = [list(self.conditions), *self.condition_groups]
432
+ self.conditions = []
433
+ if self.type != PublicFlowNodeType.condition and self.condition_groups:
434
+ raise ValueError("condition_groups are only allowed on condition nodes")
435
+ return self
436
+
184
437
 
185
438
  class FlowTransitionPatch(StrictModel):
186
439
  source: str = Field(alias="from")
@@ -189,9 +442,14 @@ class FlowTransitionPatch(StrictModel):
189
442
 
190
443
  class ViewUpsertPatch(StrictModel):
191
444
  name: str
445
+ view_key: str | None = Field(default=None, validation_alias=AliasChoices("view_key", "viewKey"))
192
446
  type: PublicViewType
193
447
  columns: list[str] = Field(default_factory=list)
194
448
  group_by: str | None = None
449
+ filters: list[ViewFilterRulePatch] = Field(default_factory=list)
450
+ start_field: str | None = Field(default=None, validation_alias=AliasChoices("start_field", "startField"))
451
+ end_field: str | None = Field(default=None, validation_alias=AliasChoices("end_field", "endField"))
452
+ title_field: str | None = Field(default=None, validation_alias=AliasChoices("title_field", "titleField"))
195
453
 
196
454
  @model_validator(mode="before")
197
455
  @classmethod
@@ -201,6 +459,14 @@ class ViewUpsertPatch(StrictModel):
201
459
  payload = dict(value)
202
460
  if "fields" in payload and "columns" not in payload:
203
461
  payload["columns"] = payload.pop("fields")
462
+ if "column_names" in payload and "columns" not in payload:
463
+ payload["columns"] = payload.pop("column_names")
464
+ if "columnNames" in payload and "columns" not in payload:
465
+ payload["columns"] = payload.pop("columnNames")
466
+ if "filter_rules" in payload and "filters" not in payload:
467
+ payload["filters"] = payload.pop("filter_rules")
468
+ if "filterRules" in payload and "filters" not in payload:
469
+ payload["filters"] = payload.pop("filterRules")
204
470
  raw_type = payload.get("type")
205
471
  if isinstance(raw_type, str):
206
472
  normalized = raw_type.strip().lower()
@@ -210,6 +476,8 @@ class ViewUpsertPatch(StrictModel):
210
476
  payload["type"] = "card"
211
477
  elif normalized == "kanban":
212
478
  payload["type"] = "board"
479
+ elif normalized == "ganttview":
480
+ payload["type"] = "gantt"
213
481
  return payload
214
482
 
215
483
  @model_validator(mode="after")
@@ -218,6 +486,8 @@ class ViewUpsertPatch(StrictModel):
218
486
  raise ValueError("table/card views require columns")
219
487
  if self.type == PublicViewType.board and not self.group_by:
220
488
  raise ValueError("board view requires group_by")
489
+ if self.type == PublicViewType.gantt and not (self.start_field and self.end_field):
490
+ raise ValueError("gantt view requires start_field and end_field")
221
491
  return self
222
492
 
223
493
 
@@ -303,6 +573,22 @@ class FlowPlanRequest(StrictModel):
303
573
  payload = dict(value)
304
574
  if str(payload.get("mode") or "").strip().lower() == "overwrite":
305
575
  payload["mode"] = "replace"
576
+ raw_preset = payload.get("preset")
577
+ if raw_preset is None and isinstance(payload.get("base_preset"), str):
578
+ raw_preset = payload["base_preset"]
579
+ payload["preset"] = raw_preset
580
+ if isinstance(raw_preset, str):
581
+ normalized_preset = raw_preset.strip().lower()
582
+ preset_aliases = {
583
+ "default_approval": FlowPreset.basic_approval.value,
584
+ "approval": FlowPreset.basic_approval.value,
585
+ "basic approval": FlowPreset.basic_approval.value,
586
+ "default_fill_then_approve": FlowPreset.basic_fill_then_approve.value,
587
+ "default-fill-then-approve": FlowPreset.basic_fill_then_approve.value,
588
+ "fill_then_approve": FlowPreset.basic_fill_then_approve.value,
589
+ }
590
+ if normalized_preset in preset_aliases:
591
+ payload["preset"] = preset_aliases[normalized_preset]
306
592
  return payload
307
593
 
308
594
 
@@ -332,7 +618,14 @@ def _normalize_field_payload(value: Any) -> Any:
332
618
  if not isinstance(value, dict):
333
619
  return value
334
620
  payload = dict(value)
621
+ if "fields" in payload and "subfields" not in payload:
622
+ payload["subfields"] = payload.pop("fields")
335
623
  raw_type = payload.get("type")
624
+ if isinstance(raw_type, int):
625
+ normalized_from_id = FIELD_TYPE_ID_ALIASES.get(raw_type)
626
+ if normalized_from_id is not None:
627
+ payload["type"] = normalized_from_id.value
628
+ return payload
336
629
  if isinstance(raw_type, str):
337
630
  normalized = FIELD_TYPE_ALIASES.get(raw_type.strip().lower())
338
631
  if normalized is not None: