@josephyan/qingflow-app-builder-mcp 0.2.0-beta.23 → 0.2.0-beta.25
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/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/server.py +1 -0
- package/src/qingflow_mcp/server_app_user.py +11 -0
- package/src/qingflow_mcp/tools/app_tools.py +109 -11
- package/src/qingflow_mcp/tools/record_tools.py +32 -7
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.25
|
|
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.25 qingflow-app-builder-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -28,6 +28,7 @@ def build_server() -> FastMCP:
|
|
|
28
28
|
instructions=(
|
|
29
29
|
"Use auth_login first, then workspace_list and workspace_select. "
|
|
30
30
|
"All resource tools operate with the logged-in user's Qingflow permissions.\n\n"
|
|
31
|
+
"If app_key is unknown, use app_list or app_search first to discover current-user visible apps in the selected workspace. "
|
|
31
32
|
"For analytics, use record_schema_get first, let the model build field_id-based DSL, "
|
|
32
33
|
"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
34
|
"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. "
|
|
@@ -6,6 +6,7 @@ from .backend_client import BackendClient
|
|
|
6
6
|
from .config import DEFAULT_PROFILE
|
|
7
7
|
from .session_store import SessionStore
|
|
8
8
|
from .tools.approval_tools import ApprovalTools
|
|
9
|
+
from .tools.app_tools import AppTools
|
|
9
10
|
from .tools.auth_tools import AuthTools
|
|
10
11
|
from .tools.directory_tools import DirectoryTools
|
|
11
12
|
from .tools.file_tools import FileTools
|
|
@@ -19,6 +20,7 @@ def build_user_server() -> FastMCP:
|
|
|
19
20
|
"Qingflow App User MCP",
|
|
20
21
|
instructions=(
|
|
21
22
|
"Use this server for Qingflow operational workflows with a schema-first path. "
|
|
23
|
+
"If app_key is unknown, use app_list or app_search first to discover current-user visible apps in the selected workspace. "
|
|
22
24
|
"For records, start with record_schema_get, then choose record_list, record_get, or record_write. "
|
|
23
25
|
"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. "
|
|
24
26
|
"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. "
|
|
@@ -29,6 +31,7 @@ def build_user_server() -> FastMCP:
|
|
|
29
31
|
sessions = SessionStore()
|
|
30
32
|
backend = BackendClient()
|
|
31
33
|
auth = AuthTools(sessions, backend)
|
|
34
|
+
apps = AppTools(sessions, backend)
|
|
32
35
|
workspace = WorkspaceTools(sessions, backend)
|
|
33
36
|
files = FileTools(sessions, backend)
|
|
34
37
|
approvals = ApprovalTools(sessions, backend)
|
|
@@ -96,6 +99,14 @@ def build_user_server() -> FastMCP:
|
|
|
96
99
|
def workspace_select(profile: str = DEFAULT_PROFILE, ws_id: int = 0) -> dict:
|
|
97
100
|
return workspace.workspace_select(profile=profile, ws_id=ws_id)
|
|
98
101
|
|
|
102
|
+
@server.tool()
|
|
103
|
+
def app_list(profile: str = DEFAULT_PROFILE) -> dict:
|
|
104
|
+
return apps.app_list(profile=profile)
|
|
105
|
+
|
|
106
|
+
@server.tool()
|
|
107
|
+
def app_search(profile: str = DEFAULT_PROFILE, keyword: str = "", page_num: int = 1, page_size: int = 50) -> dict:
|
|
108
|
+
return apps.app_search(profile=profile, keyword=keyword, page_num=page_num, page_size=page_size)
|
|
109
|
+
|
|
99
110
|
@server.tool()
|
|
100
111
|
def file_get_upload_info(
|
|
101
112
|
profile: str = DEFAULT_PROFILE,
|
|
@@ -76,14 +76,20 @@ class AppTools(ToolBase):
|
|
|
76
76
|
return self.app_publish(profile=profile, app_key=app_key, payload=payload or {})
|
|
77
77
|
|
|
78
78
|
def app_list(self, *, profile: str, ship_auth: bool = False) -> JSONObject:
|
|
79
|
-
"""
|
|
79
|
+
"""List current-user visible apps in the selected workspace."""
|
|
80
80
|
def runner(session_profile, context):
|
|
81
81
|
result = self.backend.request("GET", context, "/tag/apps")
|
|
82
|
-
|
|
82
|
+
items, source_shape = self._extract_visible_apps(result)
|
|
83
|
+
response = {
|
|
83
84
|
"profile": profile,
|
|
84
85
|
"ws_id": session_profile.selected_ws_id,
|
|
85
|
-
"items":
|
|
86
|
+
"items": items,
|
|
87
|
+
"count": len(items),
|
|
88
|
+
"source_shape": source_shape,
|
|
86
89
|
}
|
|
90
|
+
if ship_auth:
|
|
91
|
+
response["raw"] = result
|
|
92
|
+
return response
|
|
87
93
|
|
|
88
94
|
return self._run(profile, runner)
|
|
89
95
|
|
|
@@ -98,19 +104,20 @@ class AppTools(ToolBase):
|
|
|
98
104
|
|
|
99
105
|
result = self.backend.request("GET", context, "/app/item", params=params)
|
|
100
106
|
|
|
101
|
-
# Extract app list from the response
|
|
102
107
|
apps = []
|
|
103
108
|
if isinstance(result, dict):
|
|
104
109
|
items = result.get("list", [])
|
|
105
110
|
for item in items:
|
|
106
111
|
if isinstance(item, dict):
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
normalized = self._normalize_visible_app(
|
|
113
|
+
item,
|
|
114
|
+
package_tag_id=_coerce_positive_int(item.get("tagId")),
|
|
115
|
+
package_name=str(item.get("tagName") or "").strip() or None,
|
|
116
|
+
group_id=_coerce_positive_int(item.get("groupId")),
|
|
117
|
+
group_name=str(item.get("groupName") or "").strip() or None,
|
|
118
|
+
)
|
|
119
|
+
if normalized is not None:
|
|
120
|
+
apps.append(normalized)
|
|
114
121
|
|
|
115
122
|
return {
|
|
116
123
|
"profile": profile,
|
|
@@ -119,6 +126,7 @@ class AppTools(ToolBase):
|
|
|
119
126
|
"page_num": page_num,
|
|
120
127
|
"page_size": page_size,
|
|
121
128
|
"total": result.get("total") if isinstance(result, dict) else len(apps),
|
|
129
|
+
"items": apps,
|
|
122
130
|
"apps": apps,
|
|
123
131
|
}
|
|
124
132
|
|
|
@@ -424,6 +432,88 @@ class AppTools(ToolBase):
|
|
|
424
432
|
}
|
|
425
433
|
return {key: value for key, value in compact.items() if value is not None}
|
|
426
434
|
|
|
435
|
+
def _extract_visible_apps(self, result: Any) -> tuple[list[JSONObject], str]:
|
|
436
|
+
apps: list[JSONObject] = []
|
|
437
|
+
seen: set[str] = set()
|
|
438
|
+
|
|
439
|
+
def walk(
|
|
440
|
+
node: Any,
|
|
441
|
+
*,
|
|
442
|
+
package_tag_id: int | None = None,
|
|
443
|
+
package_name: str | None = None,
|
|
444
|
+
group_id: int | None = None,
|
|
445
|
+
group_name: str | None = None,
|
|
446
|
+
) -> None:
|
|
447
|
+
if isinstance(node, list):
|
|
448
|
+
for item in node:
|
|
449
|
+
walk(
|
|
450
|
+
item,
|
|
451
|
+
package_tag_id=package_tag_id,
|
|
452
|
+
package_name=package_name,
|
|
453
|
+
group_id=group_id,
|
|
454
|
+
group_name=group_name,
|
|
455
|
+
)
|
|
456
|
+
return
|
|
457
|
+
if not isinstance(node, dict):
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
next_package_tag_id = _coerce_positive_int(node.get("tagId")) or package_tag_id
|
|
461
|
+
next_package_name = str(node.get("tagName") or "").strip() or package_name
|
|
462
|
+
next_group_id = _coerce_positive_int(node.get("groupId")) or group_id
|
|
463
|
+
next_group_name = str(node.get("groupName") or node.get("groupTitle") or "").strip() or group_name
|
|
464
|
+
|
|
465
|
+
normalized = self._normalize_visible_app(
|
|
466
|
+
node,
|
|
467
|
+
package_tag_id=next_package_tag_id,
|
|
468
|
+
package_name=next_package_name,
|
|
469
|
+
group_id=next_group_id,
|
|
470
|
+
group_name=next_group_name,
|
|
471
|
+
)
|
|
472
|
+
if normalized is not None:
|
|
473
|
+
app_key = str(normalized.get("app_key") or "").strip()
|
|
474
|
+
if app_key and app_key not in seen:
|
|
475
|
+
seen.add(app_key)
|
|
476
|
+
apps.append(normalized)
|
|
477
|
+
|
|
478
|
+
for value in node.values():
|
|
479
|
+
if isinstance(value, (list, dict)):
|
|
480
|
+
walk(
|
|
481
|
+
value,
|
|
482
|
+
package_tag_id=next_package_tag_id,
|
|
483
|
+
package_name=next_package_name,
|
|
484
|
+
group_id=next_group_id,
|
|
485
|
+
group_name=next_group_name,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
walk(result)
|
|
489
|
+
return apps, type(result).__name__
|
|
490
|
+
|
|
491
|
+
def _normalize_visible_app(
|
|
492
|
+
self,
|
|
493
|
+
item: dict[str, Any],
|
|
494
|
+
*,
|
|
495
|
+
package_tag_id: int | None,
|
|
496
|
+
package_name: str | None,
|
|
497
|
+
group_id: int | None,
|
|
498
|
+
group_name: str | None,
|
|
499
|
+
) -> JSONObject | None:
|
|
500
|
+
app_key = str(item.get("appKey") or item.get("app_key") or "").strip()
|
|
501
|
+
if not app_key:
|
|
502
|
+
return None
|
|
503
|
+
title = str(item.get("title") or item.get("formTitle") or item.get("appName") or item.get("name") or app_key).strip() or app_key
|
|
504
|
+
tag_ids = item.get("tagIds") if isinstance(item.get("tagIds"), list) else []
|
|
505
|
+
compact = {
|
|
506
|
+
"app_key": app_key,
|
|
507
|
+
"title": title,
|
|
508
|
+
"form_id": item.get("formId"),
|
|
509
|
+
"tag_id": package_tag_id,
|
|
510
|
+
"package_name": package_name,
|
|
511
|
+
"group_id": group_id,
|
|
512
|
+
"group_name": group_name,
|
|
513
|
+
"tag_ids": [value for value in (_coerce_positive_int(tag_id) for tag_id in tag_ids) if value is not None],
|
|
514
|
+
}
|
|
515
|
+
return {key: value for key, value in compact.items() if value not in (None, [], "", {})}
|
|
516
|
+
|
|
427
517
|
def _count_auth_members(self, auth_payload: Any, member_key: str) -> int:
|
|
428
518
|
if not isinstance(auth_payload, dict):
|
|
429
519
|
return 0
|
|
@@ -470,3 +560,11 @@ def _normalize_form_type(value: int | str) -> int:
|
|
|
470
560
|
if text in FORM_TYPE_ALIASES:
|
|
471
561
|
return FORM_TYPE_ALIASES[text]
|
|
472
562
|
raise_tool_error(QingflowApiError.config_error("form_type must be a positive integer or one of: default, form, schema, new, draft, edit"))
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _coerce_positive_int(value: Any) -> int | None:
|
|
566
|
+
try:
|
|
567
|
+
number = int(value)
|
|
568
|
+
except (TypeError, ValueError):
|
|
569
|
+
return None
|
|
570
|
+
return number if number > 0 else None
|
|
@@ -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
|
|
@@ -1131,10 +1132,12 @@ class RecordTools(ToolBase):
|
|
|
1131
1132
|
self._ensure_allowed_analyze_keys(
|
|
1132
1133
|
item,
|
|
1133
1134
|
location=f"metrics[{idx}]",
|
|
1134
|
-
allowed_keys={"op", "field_id", "fieldId", "alias"},
|
|
1135
|
+
allowed_keys={"op", "type", "agg", "aggregation", "field_id", "fieldId", "alias"},
|
|
1135
1136
|
example="{'op': 'sum', 'field_id': 7, 'alias': '总金额'}",
|
|
1136
1137
|
)
|
|
1137
|
-
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
|
+
)
|
|
1138
1141
|
if op not in supported_ops:
|
|
1139
1142
|
raise RecordInputError(
|
|
1140
1143
|
message=f"metrics[{idx}] uses unsupported op '{op}'",
|
|
@@ -1156,7 +1159,7 @@ class RecordTools(ToolBase):
|
|
|
1156
1159
|
raise RecordInputError(
|
|
1157
1160
|
message=f"metrics[{idx}] with op 'count' must not include field_id",
|
|
1158
1161
|
error_code="INVALID_ANALYZE_METRIC",
|
|
1159
|
-
fix_hint="
|
|
1162
|
+
fix_hint="For count, omit field_id and use only {'op': 'count', 'alias': '记录数'}.",
|
|
1160
1163
|
details={"location": f"metrics[{idx}]", "op": op},
|
|
1161
1164
|
)
|
|
1162
1165
|
alias = _normalize_optional_text(item.get("alias"))
|
|
@@ -3995,16 +3998,19 @@ def _build_field_index(schema: JSONObject) -> FieldIndex:
|
|
|
3995
3998
|
*[(question, False) for question in _flatten_questions(schema.get("formQues"))],
|
|
3996
3999
|
]
|
|
3997
4000
|
for question, is_base_question in all_questions:
|
|
4001
|
+
if not _should_index_question(question):
|
|
4002
|
+
continue
|
|
3998
4003
|
que_id = _coerce_count(question.get("queId"))
|
|
3999
4004
|
title = _stringify_json(question.get("queTitle")).strip()
|
|
4000
4005
|
if que_id is None or que_id < 0 or not title:
|
|
4001
4006
|
continue
|
|
4007
|
+
can_edit = question.get("canEdit")
|
|
4002
4008
|
field = FormField(
|
|
4003
4009
|
que_id=que_id,
|
|
4004
4010
|
que_title=title,
|
|
4005
4011
|
que_type=_coerce_count(question.get("queType")),
|
|
4006
4012
|
required=bool(question.get("required") or question.get("beingRequired")),
|
|
4007
|
-
readonly=bool(question.get("readonly") or question.get("beingReadonly") or is_base_question),
|
|
4013
|
+
readonly=bool(question.get("readonly") or question.get("beingReadonly") or is_base_question or can_edit is False),
|
|
4008
4014
|
system=bool(question.get("system") or question.get("beingSystem") or is_base_question),
|
|
4009
4015
|
options=_extract_question_options(question),
|
|
4010
4016
|
aliases=[],
|
|
@@ -4023,16 +4029,35 @@ def _build_field_index(schema: JSONObject) -> FieldIndex:
|
|
|
4023
4029
|
def _flatten_questions(payload: JSONValue) -> list[JSONObject]:
|
|
4024
4030
|
flattened: list[JSONObject] = []
|
|
4025
4031
|
if isinstance(payload, dict):
|
|
4026
|
-
|
|
4032
|
+
is_question = "queId" in payload or "queTitle" in payload
|
|
4033
|
+
if is_question:
|
|
4027
4034
|
flattened.append(payload)
|
|
4028
|
-
for
|
|
4029
|
-
|
|
4035
|
+
for key in ("subQuestions", "innerQuestions", "subQues"):
|
|
4036
|
+
value = payload.get(key)
|
|
4037
|
+
if isinstance(value, list):
|
|
4038
|
+
flattened.extend(_flatten_questions(value))
|
|
4039
|
+
if not is_question:
|
|
4040
|
+
for key in ("baseQues", "formQues"):
|
|
4041
|
+
value = payload.get(key)
|
|
4042
|
+
if isinstance(value, list):
|
|
4043
|
+
flattened.extend(_flatten_questions(value))
|
|
4030
4044
|
elif isinstance(payload, list):
|
|
4031
4045
|
for item in payload:
|
|
4032
4046
|
flattened.extend(_flatten_questions(item))
|
|
4033
4047
|
return flattened
|
|
4034
4048
|
|
|
4035
4049
|
|
|
4050
|
+
def _should_index_question(question: JSONObject) -> bool:
|
|
4051
|
+
if bool(question.get("beingHide") or question.get("hidden")):
|
|
4052
|
+
return False
|
|
4053
|
+
if _coerce_count(question.get("quoteId")) is not None:
|
|
4054
|
+
return False
|
|
4055
|
+
que_type = _coerce_count(question.get("queType"))
|
|
4056
|
+
if que_type in LAYOUT_ONLY_QUE_TYPES:
|
|
4057
|
+
return False
|
|
4058
|
+
return True
|
|
4059
|
+
|
|
4060
|
+
|
|
4036
4061
|
def _extract_question_options(question: JSONObject) -> list[str]:
|
|
4037
4062
|
options = question.get("options")
|
|
4038
4063
|
if not isinstance(options, list):
|