@josephyan/qingflow-app-user-mcp 0.2.0-beta.36 → 0.2.0-beta.38
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 +1 -0
- package/skills/qingflow-record-analysis/SKILL.md +8 -5
- package/skills/qingflow-record-crud/SKILL.md +12 -6
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/builder_facade/models.py +217 -0
- package/src/qingflow_mcp/builder_facade/service.py +1002 -46
- package/src/qingflow_mcp/list_type_labels.py +24 -0
- package/src/qingflow_mcp/server.py +11 -4
- package/src/qingflow_mcp/server_app_builder.py +48 -75
- package/src/qingflow_mcp/server_app_user.py +15 -4
- package/src/qingflow_mcp/tools/ai_builder_tools.py +528 -165
- package/src/qingflow_mcp/tools/app_tools.py +133 -1
- package/src/qingflow_mcp/tools/record_tools.py +1308 -159
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.38
|
|
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.38 qingflow-app-user-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -31,6 +31,7 @@ Route to exactly one of these specialized paths:
|
|
|
31
31
|
## Routing Rules
|
|
32
32
|
|
|
33
33
|
- If the user does not know the target `app_key`, discover apps first with `app_list` or `app_search`, then route to the specialized skill
|
|
34
|
+
- If the app is known but the available data range is unclear, call `app_get` first and inspect `accessible_views`
|
|
34
35
|
- If the task is about browsing, reading, creating, updating, deleting, attachments, relations, subtable writes, or member/department-field candidate lookup, switch to `$qingflow-record-crud`
|
|
35
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`
|
|
36
37
|
- If the task is about grouped distributions, ratios, rankings, trends, insights, or any final statistical conclusion, switch to `$qingflow-record-analysis`
|
|
@@ -9,13 +9,15 @@ metadata:
|
|
|
9
9
|
|
|
10
10
|
This skill is for final statistical conclusions only.
|
|
11
11
|
Assumes MCP is connected, authenticated, and on the correct workspace.
|
|
12
|
-
Analysis tasks must start with `record_schema_get`. Read top-level `fields` and `suggested_*`, then build field_id-based DSLs only.
|
|
12
|
+
Analysis tasks must start with `app_get`, then `record_schema_get(schema_mode="browse", view_id=...)`. Read top-level `fields` and `suggested_*`, then build field_id-based DSLs only.
|
|
13
|
+
Analysis tasks must start with `record_schema_get`.
|
|
14
|
+
If `app_get.accessible_views` marks a view with `analysis_supported=false`, do not use that view for `record_list` or `record_analyze`. `boardView` and `ganttView` are special UI views, not list/analyze targets.
|
|
13
15
|
|
|
14
|
-
## Step 1: `
|
|
16
|
+
## Step 1: `app_get` → Step 2: `record_schema_get(schema_mode="browse", view_id=...)` → Step 3: build DSL → Step 4: `record_analyze`
|
|
15
17
|
|
|
16
|
-
This is the ONLY execution order. Never skip
|
|
18
|
+
This is the ONLY execution order. Never skip `app_get` when the browse range is unclear. Never call `record_analyze` without a browse schema.
|
|
17
19
|
|
|
18
|
-
Core tools: `record_schema_get`, `record_analyze`. Use `record_list`/`record_get` only for post-analysis samples; task/comment work stays in [$qingflow-task-ops](/Users/yanqidong/Documents/qingflow-next/.codex/skills/qingflow-task-ops/SKILL.md).
|
|
20
|
+
Core tools: `app_get`, `record_schema_get`, `record_analyze`. Use `record_list`/`record_get` only for post-analysis samples; task/comment work stays in [$qingflow-task-ops](/Users/yanqidong/Documents/qingflow-next/.codex/skills/qingflow-task-ops/SKILL.md).
|
|
19
21
|
|
|
20
22
|
---
|
|
21
23
|
|
|
@@ -105,7 +107,8 @@ Top-level arguments:
|
|
|
105
107
|
- `dimensions`: `[]` = whole-table summary; `[{...}]` = grouped.
|
|
106
108
|
- `strict_full`: `true` for final conclusions. `false` allows partial results.
|
|
107
109
|
- `limit`: limits returned rows only, not scan scope.
|
|
108
|
-
- `
|
|
110
|
+
- `view_id`: the canonical browse selector. Prefer choosing it from `app_get.accessible_views`.
|
|
111
|
+
- Prefer `view_id` entries where `analysis_supported=true`. If a view is `boardView` or `ganttView`, switch to a system or table-style custom view before calling `record_analyze`.
|
|
109
112
|
- `bucket` in dimensions: only for `suggested_time_fields`. Values: `day`/`week`/`month`/`quarter`/`year`/`null`.
|
|
110
113
|
|
|
111
114
|
---
|
|
@@ -16,9 +16,9 @@ Assumes MCP is connected, authenticated, and on the correct workspace.
|
|
|
16
16
|
|
|
17
17
|
Use exactly one of these default paths:
|
|
18
18
|
|
|
19
|
-
1. Browse records: `record_schema_get -> record_list`
|
|
20
|
-
2. Read one record: `record_schema_get -> record_get`
|
|
21
|
-
3. Write records: `record_schema_get -> record_write`
|
|
19
|
+
1. Browse records: `app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_list`
|
|
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
22
|
|
|
23
23
|
## Core Tools
|
|
24
24
|
|
|
@@ -27,12 +27,15 @@ Use exactly one of these default paths:
|
|
|
27
27
|
- `record_get`
|
|
28
28
|
- `record_write`
|
|
29
29
|
|
|
30
|
-
`record_schema_get`
|
|
30
|
+
`record_schema_get(schema_mode="applicant")` exposes the current user's applicant-node visible write/create fields.
|
|
31
|
+
`record_schema_get(schema_mode="browse", view_id=...)` exposes browse-schema fields for the selected accessible view.
|
|
32
|
+
Read top-level `fields` and `suggested_*`; if a field is missing, treat it as unavailable in the current permission scope.
|
|
31
33
|
|
|
32
34
|
## Supporting Tools
|
|
33
35
|
|
|
34
36
|
- `app_list`
|
|
35
37
|
- `app_search`
|
|
38
|
+
- `app_get`
|
|
36
39
|
- `record_member_candidates`
|
|
37
40
|
- `record_department_candidates`
|
|
38
41
|
- `directory_search`
|
|
@@ -50,7 +53,9 @@ Use `record_member_candidates` / `record_department_candidates` as the default l
|
|
|
50
53
|
2. Ensure workspace is selected
|
|
51
54
|
3. Confirm target app and whether the task is browse / detail / write / analysis
|
|
52
55
|
4. If `app_key` is unknown, use `app_list` or `app_search` first
|
|
53
|
-
5.
|
|
56
|
+
5. If browse/read range is unclear, run `app_get` and choose from `accessible_views`
|
|
57
|
+
6. Run `record_schema_get(schema_mode="browse", view_id=...)` before browse/read
|
|
58
|
+
7. Run `record_schema_get(schema_mode="applicant")` before write
|
|
54
59
|
6. If the request is analysis-like, switch to [$qingflow-record-analysis](/Users/yanqidong/Documents/qingflow-next/.codex/skills/qingflow-record-analysis/SKILL.md)
|
|
55
60
|
7. If the request is write-like, decide `insert / update / delete` before building any payload
|
|
56
61
|
8. If fields are still ambiguous after `record_schema_get`, ask the user to confirm from a short candidate list instead of guessing
|
|
@@ -60,6 +65,7 @@ Use `record_member_candidates` / `record_department_candidates` as the default l
|
|
|
60
65
|
## Record Read Rules
|
|
61
66
|
|
|
62
67
|
- Use `record_list` for browse/export/sample inspection only
|
|
68
|
+
- Prefer choosing a `view_id` from `app_get.accessible_views`
|
|
63
69
|
- For `columns`, use `[{ "field_id": 12 }]`
|
|
64
70
|
- For `where`, use `{ "field_id": 12, "op": "eq", "value": "进行中" }`
|
|
65
71
|
- For `order_by`, use `{ "field_id": 18, "direction": "desc" }`
|
|
@@ -73,7 +79,7 @@ Use `record_write` as the only default write tool.
|
|
|
73
79
|
|
|
74
80
|
### Write workflow
|
|
75
81
|
|
|
76
|
-
1. Run `record_schema_get`
|
|
82
|
+
1. Run `record_schema_get(schema_mode="applicant")`
|
|
77
83
|
2. Decide whether the task is `insert`, `update`, or `delete`
|
|
78
84
|
3. For relation fields, read `target_app_key / target_app_name` from schema first
|
|
79
85
|
4. For member fields with unknown ids, run `record_member_candidates`
|
|
@@ -69,6 +69,14 @@ class PublicViewType(str, Enum):
|
|
|
69
69
|
gantt = "gantt"
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
class PublicChartType(str, Enum):
|
|
73
|
+
target = "target"
|
|
74
|
+
pie = "pie"
|
|
75
|
+
bar = "bar"
|
|
76
|
+
line = "line"
|
|
77
|
+
table = "table"
|
|
78
|
+
|
|
79
|
+
|
|
72
80
|
class LayoutApplyMode(str, Enum):
|
|
73
81
|
merge = "merge"
|
|
74
82
|
replace = "replace"
|
|
@@ -491,6 +499,215 @@ class ViewUpsertPatch(StrictModel):
|
|
|
491
499
|
return self
|
|
492
500
|
|
|
493
501
|
|
|
502
|
+
class ChartFilterRulePatch(StrictModel):
|
|
503
|
+
field_name: str = Field(validation_alias=AliasChoices("field_name", "fieldName", "field", "name"))
|
|
504
|
+
operator: ViewFilterOperator = Field(validation_alias=AliasChoices("operator", "op"))
|
|
505
|
+
values: list[Any] = Field(default_factory=list)
|
|
506
|
+
|
|
507
|
+
@model_validator(mode="before")
|
|
508
|
+
@classmethod
|
|
509
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
510
|
+
if not isinstance(value, dict):
|
|
511
|
+
return value
|
|
512
|
+
payload = dict(value)
|
|
513
|
+
if "value" in payload and "values" not in payload:
|
|
514
|
+
payload["values"] = [payload.pop("value")]
|
|
515
|
+
raw_operator = payload.get("operator", payload.get("op"))
|
|
516
|
+
if isinstance(raw_operator, str):
|
|
517
|
+
normalized = raw_operator.strip().lower()
|
|
518
|
+
operator_aliases = {
|
|
519
|
+
"equals": ViewFilterOperator.eq.value,
|
|
520
|
+
"equal": ViewFilterOperator.eq.value,
|
|
521
|
+
"=": ViewFilterOperator.eq.value,
|
|
522
|
+
"not_equals": ViewFilterOperator.neq.value,
|
|
523
|
+
"not_equal": ViewFilterOperator.neq.value,
|
|
524
|
+
"!=": ViewFilterOperator.neq.value,
|
|
525
|
+
">=": ViewFilterOperator.gte.value,
|
|
526
|
+
"<=": ViewFilterOperator.lte.value,
|
|
527
|
+
"any_of": ViewFilterOperator.in_.value,
|
|
528
|
+
"one_of": ViewFilterOperator.in_.value,
|
|
529
|
+
"between_any": ViewFilterOperator.in_.value,
|
|
530
|
+
"empty": ViewFilterOperator.is_empty.value,
|
|
531
|
+
"is blank": ViewFilterOperator.is_empty.value,
|
|
532
|
+
"blank": ViewFilterOperator.is_empty.value,
|
|
533
|
+
"not_empty": ViewFilterOperator.not_empty.value,
|
|
534
|
+
"not blank": ViewFilterOperator.not_empty.value,
|
|
535
|
+
}
|
|
536
|
+
if normalized in operator_aliases:
|
|
537
|
+
payload["operator"] = operator_aliases[normalized]
|
|
538
|
+
elif "operator" not in payload:
|
|
539
|
+
payload["operator"] = normalized
|
|
540
|
+
payload.pop("op", None)
|
|
541
|
+
return payload
|
|
542
|
+
|
|
543
|
+
@model_validator(mode="after")
|
|
544
|
+
def validate_shape(self) -> "ChartFilterRulePatch":
|
|
545
|
+
if self.operator in {ViewFilterOperator.is_empty, ViewFilterOperator.not_empty}:
|
|
546
|
+
self.values = []
|
|
547
|
+
return self
|
|
548
|
+
if not self.values:
|
|
549
|
+
raise ValueError("chart filter rule requires values")
|
|
550
|
+
return self
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
class ChartUpsertPatch(StrictModel):
|
|
554
|
+
chart_id: str | None = None
|
|
555
|
+
name: str
|
|
556
|
+
chart_type: PublicChartType
|
|
557
|
+
dimension_field_ids: list[str] = Field(default_factory=list)
|
|
558
|
+
indicator_field_ids: list[str] = Field(default_factory=list)
|
|
559
|
+
filters: list[ChartFilterRulePatch] = Field(default_factory=list)
|
|
560
|
+
question_config: list[dict[str, Any]] = Field(default_factory=list)
|
|
561
|
+
user_config: list[dict[str, Any]] = Field(default_factory=list)
|
|
562
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
563
|
+
|
|
564
|
+
@model_validator(mode="before")
|
|
565
|
+
@classmethod
|
|
566
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
567
|
+
if not isinstance(value, dict):
|
|
568
|
+
return value
|
|
569
|
+
payload = dict(value)
|
|
570
|
+
if "id" in payload and "chart_id" not in payload:
|
|
571
|
+
payload["chart_id"] = payload.pop("id")
|
|
572
|
+
if "type" in payload and "chart_type" not in payload:
|
|
573
|
+
payload["chart_type"] = payload.pop("type")
|
|
574
|
+
if "dimension_fields" in payload and "dimension_field_ids" not in payload:
|
|
575
|
+
payload["dimension_field_ids"] = payload.pop("dimension_fields")
|
|
576
|
+
if "indicator_fields" in payload and "indicator_field_ids" not in payload:
|
|
577
|
+
payload["indicator_field_ids"] = payload.pop("indicator_fields")
|
|
578
|
+
if "metric_field_ids" in payload and "indicator_field_ids" not in payload:
|
|
579
|
+
payload["indicator_field_ids"] = payload.pop("metric_field_ids")
|
|
580
|
+
raw_type = payload.get("chart_type")
|
|
581
|
+
if isinstance(raw_type, str):
|
|
582
|
+
normalized = raw_type.strip().lower()
|
|
583
|
+
aliases = {
|
|
584
|
+
"targetchart": PublicChartType.target.value,
|
|
585
|
+
"piechart": PublicChartType.pie.value,
|
|
586
|
+
"barchart": PublicChartType.bar.value,
|
|
587
|
+
"linechart": PublicChartType.line.value,
|
|
588
|
+
"tablechart": PublicChartType.table.value,
|
|
589
|
+
}
|
|
590
|
+
if normalized in aliases:
|
|
591
|
+
payload["chart_type"] = aliases[normalized]
|
|
592
|
+
return payload
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
class ChartApplyRequest(StrictModel):
|
|
596
|
+
app_key: str
|
|
597
|
+
upsert_charts: list[ChartUpsertPatch] = Field(default_factory=list)
|
|
598
|
+
remove_chart_ids: list[str] = Field(default_factory=list)
|
|
599
|
+
reorder_chart_ids: list[str] = Field(default_factory=list)
|
|
600
|
+
|
|
601
|
+
@model_validator(mode="after")
|
|
602
|
+
def validate_shape(self) -> "ChartApplyRequest":
|
|
603
|
+
if not self.upsert_charts and not self.remove_chart_ids and not self.reorder_chart_ids:
|
|
604
|
+
raise ValueError("chart apply requires at least one upsert, remove, or reorder operation")
|
|
605
|
+
return self
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
class PortalComponentPositionPatch(StrictModel):
|
|
609
|
+
pc_x: int = Field(default=0, validation_alias=AliasChoices("pc_x", "pcX", "x"))
|
|
610
|
+
pc_y: int = Field(default=0, validation_alias=AliasChoices("pc_y", "pcY", "y"))
|
|
611
|
+
pc_w: int = Field(default=12, validation_alias=AliasChoices("pc_w", "pcW", "w"))
|
|
612
|
+
pc_h: int = Field(default=8, validation_alias=AliasChoices("pc_h", "pcH", "h"))
|
|
613
|
+
mobile_x: int = Field(default=0, validation_alias=AliasChoices("mobile_x", "mobileX"))
|
|
614
|
+
mobile_y: int = Field(default=0, validation_alias=AliasChoices("mobile_y", "mobileY"))
|
|
615
|
+
mobile_w: int = Field(default=12, validation_alias=AliasChoices("mobile_w", "mobileW"))
|
|
616
|
+
mobile_h: int = Field(default=8, validation_alias=AliasChoices("mobile_h", "mobileH"))
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
class PortalChartRefPatch(StrictModel):
|
|
620
|
+
app_key: str
|
|
621
|
+
chart_id: str | None = None
|
|
622
|
+
chart_name: str | None = None
|
|
623
|
+
|
|
624
|
+
@model_validator(mode="after")
|
|
625
|
+
def validate_target(self) -> "PortalChartRefPatch":
|
|
626
|
+
if not (self.chart_id or self.chart_name):
|
|
627
|
+
raise ValueError("chart_ref requires chart_id or chart_name")
|
|
628
|
+
return self
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
class PortalViewRefPatch(StrictModel):
|
|
632
|
+
app_key: str
|
|
633
|
+
view_key: str | None = None
|
|
634
|
+
view_name: str | None = None
|
|
635
|
+
|
|
636
|
+
@model_validator(mode="after")
|
|
637
|
+
def validate_target(self) -> "PortalViewRefPatch":
|
|
638
|
+
if not (self.view_key or self.view_name):
|
|
639
|
+
raise ValueError("view_ref requires view_key or view_name")
|
|
640
|
+
return self
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
class PortalSectionPatch(StrictModel):
|
|
644
|
+
title: str
|
|
645
|
+
source_type: str = Field(validation_alias=AliasChoices("source_type", "sourceType"))
|
|
646
|
+
position: PortalComponentPositionPatch | None = None
|
|
647
|
+
dash_style_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("dash_style_config", "dashStyleConfigBO"))
|
|
648
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
649
|
+
chart_ref: PortalChartRefPatch | None = None
|
|
650
|
+
view_ref: PortalViewRefPatch | None = None
|
|
651
|
+
text: str | None = None
|
|
652
|
+
url: str | None = None
|
|
653
|
+
|
|
654
|
+
@model_validator(mode="before")
|
|
655
|
+
@classmethod
|
|
656
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
657
|
+
if not isinstance(value, dict):
|
|
658
|
+
return value
|
|
659
|
+
payload = dict(value)
|
|
660
|
+
raw_type = payload.get("source_type", payload.get("sourceType"))
|
|
661
|
+
if isinstance(raw_type, str):
|
|
662
|
+
payload["source_type"] = raw_type.strip().lower()
|
|
663
|
+
if "chartRef" in payload and "chart_ref" not in payload:
|
|
664
|
+
payload["chart_ref"] = payload.pop("chartRef")
|
|
665
|
+
if "viewRef" in payload and "view_ref" not in payload:
|
|
666
|
+
payload["view_ref"] = payload.pop("viewRef")
|
|
667
|
+
if "dashStyleConfigBO" in payload and "dash_style_config" not in payload:
|
|
668
|
+
payload["dash_style_config"] = payload.pop("dashStyleConfigBO")
|
|
669
|
+
return payload
|
|
670
|
+
|
|
671
|
+
@model_validator(mode="after")
|
|
672
|
+
def validate_shape(self) -> "PortalSectionPatch":
|
|
673
|
+
supported = {"chart", "view", "grid", "filter", "text", "link"}
|
|
674
|
+
if self.source_type not in supported:
|
|
675
|
+
raise ValueError(f"unsupported portal source_type '{self.source_type}'")
|
|
676
|
+
if self.source_type == "chart" and self.chart_ref is None:
|
|
677
|
+
raise ValueError("chart section requires chart_ref")
|
|
678
|
+
if self.source_type == "view" and self.view_ref is None:
|
|
679
|
+
raise ValueError("view section requires view_ref")
|
|
680
|
+
if self.source_type == "text" and self.text is None:
|
|
681
|
+
raise ValueError("text section requires text")
|
|
682
|
+
if self.source_type == "link" and self.url is None:
|
|
683
|
+
raise ValueError("link section requires url")
|
|
684
|
+
return self
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
class PortalApplyRequest(StrictModel):
|
|
688
|
+
dash_key: str | None = None
|
|
689
|
+
dash_name: str | None = None
|
|
690
|
+
package_tag_id: int | None = None
|
|
691
|
+
publish: bool = True
|
|
692
|
+
sections: list[PortalSectionPatch] = Field(default_factory=list)
|
|
693
|
+
auth: dict[str, Any] | None = None
|
|
694
|
+
icon: str | None = None
|
|
695
|
+
color: str | None = None
|
|
696
|
+
hide_copyright: bool | None = Field(default=None, validation_alias=AliasChoices("hide_copyright", "hideCopyright"))
|
|
697
|
+
dash_global_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("dash_global_config", "dashGlobalConfig"))
|
|
698
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
699
|
+
|
|
700
|
+
@model_validator(mode="after")
|
|
701
|
+
def validate_shape(self) -> "PortalApplyRequest":
|
|
702
|
+
if not self.dash_key and not self.package_tag_id:
|
|
703
|
+
raise ValueError("package_tag_id is required when dash_key is empty")
|
|
704
|
+
if not self.dash_key and not self.dash_name:
|
|
705
|
+
raise ValueError("dash_name is required when creating a portal")
|
|
706
|
+
if not self.sections:
|
|
707
|
+
raise ValueError("portal apply requires a non-empty sections list")
|
|
708
|
+
return self
|
|
709
|
+
|
|
710
|
+
|
|
494
711
|
FieldPatch.model_rebuild()
|
|
495
712
|
|
|
496
713
|
|