@josephyan/qingflow-app-builder-mcp 0.2.0-beta.22 → 0.2.0-beta.24

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-builder-mcp@0.2.0-beta.22
6
+ npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.24
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.22 qingflow-app-builder-mcp
12
+ npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.24 qingflow-app-builder-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-builder-mcp",
3
- "version": "0.2.0-beta.22",
3
+ "version": "0.2.0-beta.24",
4
4
  "description": "Builder MCP for Qingflow app/package/system design and staged solution 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.0b22"
7
+ version = "0.2.0b24"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,4 +2,4 @@ from __future__ import annotations
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0b22"
5
+ __version__ = "0.2.0b23"
@@ -30,8 +30,9 @@ def build_server() -> FastMCP:
30
30
  "All resource tools operate with the logged-in user's Qingflow permissions.\n\n"
31
31
  "For analytics, use record_schema_get first, let the model build field_id-based DSL, "
32
32
  "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
+ "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. "
33
34
  "For operational record reads, use record_schema_get first, then record_list or record_get. "
34
- "For writes, use record_schema_get and then call record_write once; it performs internal preflight before any apply.\n\n"
35
+ "For writes, use record_schema_get and then call record_write once; it performs internal preflight before any apply and refuses fields outside the applicant-node writable schema.\n\n"
35
36
  "Task Center (待办/已办) handling:\n"
36
37
  "- Use task_summary to get headline counts.\n"
37
38
  "- Use task_list for flat task browsing with task_box and flow_status.\n"
@@ -20,6 +20,7 @@ def build_user_server() -> FastMCP:
20
20
  instructions=(
21
21
  "Use this server for Qingflow operational workflows with a schema-first path. "
22
22
  "For records, start with record_schema_get, then choose record_list, record_get, or record_write. "
23
+ "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. "
23
24
  "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. "
24
25
  "For task center, use task_summary, task_list, and task_facets before any explicit task action. "
25
26
  "Avoid builder-side app or schema changes here."
@@ -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
@@ -90,6 +91,14 @@ class ViewSelection:
90
91
  conditions: list[list[ViewFilterCondition]]
91
92
 
92
93
 
94
+ @dataclass(slots=True)
95
+ class WorkflowNodeRef:
96
+ workflow_node_id: int
97
+ name: str
98
+ type: str
99
+ raw: JSONObject
100
+
101
+
93
102
  @dataclass(slots=True)
94
103
  class RecordInputError(Exception):
95
104
  message: str
@@ -134,7 +143,8 @@ FIELD_LOOKUP_STRIP_RE = re.compile(r"[\s_()()\[\]【】{}<>·/\\::-]+")
134
143
  class RecordTools(ToolBase):
135
144
  def __init__(self, sessions, backend) -> None: # type: ignore[no-untyped-def]
136
145
  super().__init__(sessions, backend)
137
- self._form_cache: dict[tuple[str, str], JSONObject] = {}
146
+ self._form_cache: dict[tuple[str, str, str, int], JSONObject] = {}
147
+ self._applicant_node_cache: dict[tuple[str, str], WorkflowNodeRef] = {}
138
148
  self._view_list_cache: dict[tuple[str, str], list[JSONObject]] = {}
139
149
  self._view_config_cache: dict[tuple[str, str], JSONObject] = {}
140
150
 
@@ -286,9 +296,10 @@ class RecordTools(ToolBase):
286
296
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
287
297
 
288
298
  def runner(session_profile, context):
299
+ applicant_node = self._resolve_applicant_node(profile, context, app_key, force_refresh=False)
289
300
  index = self._get_field_index(profile, context, app_key, force_refresh=False)
290
301
  view_selection = self._resolve_view_selection(profile, context, app_key, view_key=view_key, view_name=view_name)
291
- fields = [self._schema_field_payload(field) for field in index.by_id.values()]
302
+ fields = [self._schema_field_payload(field, workflow_node_id=applicant_node.workflow_node_id) for field in index.by_id.values()]
292
303
  suggested_dimensions = [
293
304
  {"field_id": item["field_id"], "title": item["title"]}
294
305
  for item in fields
@@ -312,6 +323,12 @@ class RecordTools(ToolBase):
312
323
  "request_route": self._request_route_payload(context),
313
324
  "data": {
314
325
  "app_key": app_key,
326
+ "schema_scope": "applicant_node",
327
+ "workflow_node": {
328
+ "workflow_node_id": applicant_node.workflow_node_id,
329
+ "name": applicant_node.name,
330
+ "type": applicant_node.type,
331
+ },
315
332
  "view_resolution": _view_selection_payload(view_selection),
316
333
  "fields": fields,
317
334
  "suggested_dimensions": suggested_dimensions,
@@ -531,31 +548,39 @@ class RecordTools(ToolBase):
531
548
  }
532
549
  return response
533
550
 
534
- raw = self.record_get(
535
- profile=profile,
536
- app_key=app_key,
537
- apply_id=record_id,
538
- role=1,
539
- list_type=None,
540
- audit_node_id=workflow_node_id,
541
- )
542
- return {
543
- "profile": profile,
544
- "ws_id": raw.get("ws_id"),
545
- "ok": bool(raw.get("ok", True)),
546
- "request_route": raw.get("request_route"),
547
- "warnings": [],
548
- "output_profile": normalized_output_profile,
549
- "data": {
550
- "app_key": app_key,
551
- "record_id": record_id,
552
- "record": raw.get("result"),
553
- "selection": {
554
- "columns": columns,
555
- "workflow_node_id": workflow_node_id,
551
+ def runner(session_profile, context):
552
+ index = self._get_field_index(profile, context, app_key, force_refresh=False)
553
+ selected_fields = list(index.by_id.values())
554
+ result = self.backend.request(
555
+ "GET",
556
+ context,
557
+ f"/app/{app_key}/apply/{record_id}",
558
+ params={"role": 1},
559
+ )
560
+ answer_list = result.get("answers") if isinstance(result, dict) and isinstance(result.get("answers"), list) else []
561
+ row = _build_flat_row(cast(list[JSONValue], answer_list), selected_fields, apply_id=record_id)
562
+ response: JSONObject = {
563
+ "profile": profile,
564
+ "ws_id": session_profile.selected_ws_id,
565
+ "ok": True,
566
+ "request_route": self._request_route_payload(context),
567
+ "warnings": [],
568
+ "output_profile": normalized_output_profile,
569
+ "data": {
570
+ "app_key": app_key,
571
+ "record_id": record_id,
572
+ "record": row,
573
+ "selection": {
574
+ "columns": columns,
575
+ "workflow_node_id": workflow_node_id,
576
+ },
556
577
  },
557
- },
558
- }
578
+ }
579
+ if normalized_output_profile == "verbose":
580
+ response["data"]["debug"] = {"raw_record": result}
581
+ return response
582
+
583
+ return self._run_record_tool(profile, runner)
559
584
 
560
585
  def record_write(
561
586
  self,
@@ -712,7 +737,7 @@ class RecordTools(ToolBase):
712
737
  preflight=None,
713
738
  )
714
739
 
715
- def _schema_field_payload(self, field: FormField) -> JSONObject:
740
+ def _schema_field_payload(self, field: FormField, *, workflow_node_id: int) -> JSONObject:
716
741
  write_hints = self._schema_write_hints(field)
717
742
  return {
718
743
  "field_id": field.que_id,
@@ -725,6 +750,8 @@ class RecordTools(ToolBase):
725
750
  "role_hints": self._schema_role_hints(field),
726
751
  "readable": True,
727
752
  "writable": write_hints["writable"],
753
+ "permission_scope": "applicant_node",
754
+ "workflow_node_id": workflow_node_id,
728
755
  "write_kind": write_hints["write_kind"],
729
756
  "supported_read_ops": write_hints["supported_read_ops"],
730
757
  "supported_write_ops": write_hints["supported_write_ops"],
@@ -1105,10 +1132,12 @@ class RecordTools(ToolBase):
1105
1132
  self._ensure_allowed_analyze_keys(
1106
1133
  item,
1107
1134
  location=f"metrics[{idx}]",
1108
- allowed_keys={"op", "field_id", "fieldId", "alias"},
1135
+ allowed_keys={"op", "type", "agg", "aggregation", "field_id", "fieldId", "alias"},
1109
1136
  example="{'op': 'sum', 'field_id': 7, 'alias': '总金额'}",
1110
1137
  )
1111
- op = _normalize_optional_text(item.get("op"))
1138
+ op = _normalize_optional_text(
1139
+ item.get("op") or item.get("type") or item.get("agg") or item.get("aggregation")
1140
+ )
1112
1141
  if op not in supported_ops:
1113
1142
  raise RecordInputError(
1114
1143
  message=f"metrics[{idx}] uses unsupported op '{op}'",
@@ -1127,12 +1156,8 @@ class RecordTools(ToolBase):
1127
1156
  details={"location": f"metrics[{idx}]", "field": _field_ref_payload(field), "op": op},
1128
1157
  )
1129
1158
  elif item.get("field_id", item.get("fieldId")) is not None:
1130
- raise RecordInputError(
1131
- message=f"metrics[{idx}] with op 'count' must not include field_id",
1132
- error_code="INVALID_ANALYZE_METRIC",
1133
- fix_hint="Remove field_id from count metrics.",
1134
- details={"location": f"metrics[{idx}]", "op": op},
1135
- )
1159
+ # LLM 经常给 count 传 field_id,静默忽略而非报错
1160
+ pass
1136
1161
  alias = _normalize_optional_text(item.get("alias"))
1137
1162
  if alias is None:
1138
1163
  if op == "count":
@@ -2390,10 +2415,16 @@ class RecordTools(ToolBase):
2390
2415
  return self._run_record_tool(profile, runner)
2391
2416
 
2392
2417
  def _get_form_schema(self, profile: str, context, app_key: str, *, force_refresh: bool) -> JSONObject: # type: ignore[no-untyped-def]
2393
- cache_key = (profile, app_key)
2418
+ applicant_node = self._resolve_applicant_node(profile, context, app_key, force_refresh=force_refresh)
2419
+ cache_key = (profile, app_key, "applicant_node", applicant_node.workflow_node_id)
2394
2420
  if not force_refresh and cache_key in self._form_cache:
2395
2421
  return self._form_cache[cache_key]
2396
- schema = self.backend.request("GET", context, f"/app/{app_key}/form", params={"type": 1})
2422
+ schema = self.backend.request(
2423
+ "GET",
2424
+ context,
2425
+ f"/app/{app_key}/form",
2426
+ params={"type": 1, "beingApply": True, "auditNodeId": applicant_node.workflow_node_id},
2427
+ )
2397
2428
  normalized = _normalize_form_schema(schema)
2398
2429
  self._form_cache[cache_key] = normalized
2399
2430
  return normalized
@@ -2401,6 +2432,26 @@ class RecordTools(ToolBase):
2401
2432
  def _get_field_index(self, profile: str, context, app_key: str, *, force_refresh: bool) -> FieldIndex: # type: ignore[no-untyped-def]
2402
2433
  return _build_field_index(self._get_form_schema(profile, context, app_key, force_refresh=force_refresh))
2403
2434
 
2435
+ def _resolve_applicant_node(self, profile: str, context, app_key: str, *, force_refresh: bool) -> WorkflowNodeRef: # type: ignore[no-untyped-def]
2436
+ cache_key = (profile, app_key)
2437
+ if not force_refresh and cache_key in self._applicant_node_cache:
2438
+ return self._applicant_node_cache[cache_key]
2439
+ payload = self.backend.request("GET", context, f"/app/{app_key}/auditNodes")
2440
+ applicant_node = _extract_applicant_node(payload)
2441
+ if applicant_node is None:
2442
+ raise_tool_error(
2443
+ QingflowApiError(
2444
+ category="config",
2445
+ message=f"cannot resolve applicant node for app {app_key}",
2446
+ details={
2447
+ "error_code": "APPLICANT_NODE_NOT_FOUND",
2448
+ "fix_hint": "Ensure the app has a workflow applicant node before using user-side record tools.",
2449
+ },
2450
+ )
2451
+ )
2452
+ self._applicant_node_cache[cache_key] = applicant_node
2453
+ return applicant_node
2454
+
2404
2455
  def _get_view_list(self, profile: str, context, app_key: str) -> list[JSONObject]: # type: ignore[no-untyped-def]
2405
2456
  cache_key = (profile, app_key)
2406
2457
  if cache_key in self._view_list_cache:
@@ -3883,6 +3934,30 @@ def _normalize_view_list(payload: JSONValue) -> list[JSONObject]:
3883
3934
  return flattened
3884
3935
 
3885
3936
 
3937
+ def _normalize_audit_nodes(payload: JSONValue) -> list[JSONObject]:
3938
+ if isinstance(payload, list):
3939
+ return [item for item in payload if isinstance(item, dict)]
3940
+ if isinstance(payload, dict):
3941
+ return [item for item in payload.values() if isinstance(item, dict)]
3942
+ return []
3943
+
3944
+
3945
+ def _extract_applicant_node(payload: JSONValue) -> WorkflowNodeRef | None:
3946
+ for item in _normalize_audit_nodes(payload):
3947
+ node_type = _coerce_count(item.get("type"))
3948
+ deal_type = _coerce_count(item.get("dealType"))
3949
+ workflow_node_id = _coerce_count(item.get("auditNodeId"))
3950
+ if workflow_node_id is None or node_type != 0 or deal_type != 3:
3951
+ continue
3952
+ return WorkflowNodeRef(
3953
+ workflow_node_id=workflow_node_id,
3954
+ name=_normalize_optional_text(item.get("auditNodeName")) or str(workflow_node_id),
3955
+ type="applicant",
3956
+ raw=item,
3957
+ )
3958
+ return None
3959
+
3960
+
3886
3961
  def _compile_view_conditions(config: JSONObject) -> list[list[ViewFilterCondition]]:
3887
3962
  raw_limit = config.get("viewgraphLimit")
3888
3963
  if not isinstance(raw_limit, list):
@@ -3919,16 +3994,19 @@ def _build_field_index(schema: JSONObject) -> FieldIndex:
3919
3994
  *[(question, False) for question in _flatten_questions(schema.get("formQues"))],
3920
3995
  ]
3921
3996
  for question, is_base_question in all_questions:
3997
+ if not _should_index_question(question):
3998
+ continue
3922
3999
  que_id = _coerce_count(question.get("queId"))
3923
4000
  title = _stringify_json(question.get("queTitle")).strip()
3924
4001
  if que_id is None or que_id < 0 or not title:
3925
4002
  continue
4003
+ can_edit = question.get("canEdit")
3926
4004
  field = FormField(
3927
4005
  que_id=que_id,
3928
4006
  que_title=title,
3929
4007
  que_type=_coerce_count(question.get("queType")),
3930
4008
  required=bool(question.get("required") or question.get("beingRequired")),
3931
- readonly=bool(question.get("readonly") or question.get("beingReadonly") or is_base_question),
4009
+ readonly=bool(question.get("readonly") or question.get("beingReadonly") or is_base_question or can_edit is False),
3932
4010
  system=bool(question.get("system") or question.get("beingSystem") or is_base_question),
3933
4011
  options=_extract_question_options(question),
3934
4012
  aliases=[],
@@ -3947,16 +4025,35 @@ def _build_field_index(schema: JSONObject) -> FieldIndex:
3947
4025
  def _flatten_questions(payload: JSONValue) -> list[JSONObject]:
3948
4026
  flattened: list[JSONObject] = []
3949
4027
  if isinstance(payload, dict):
3950
- if "queId" in payload or "queTitle" in payload:
4028
+ is_question = "queId" in payload or "queTitle" in payload
4029
+ if is_question:
3951
4030
  flattened.append(payload)
3952
- for value in payload.values():
3953
- flattened.extend(_flatten_questions(value))
4031
+ for key in ("subQuestions", "innerQuestions", "subQues"):
4032
+ value = payload.get(key)
4033
+ if isinstance(value, list):
4034
+ flattened.extend(_flatten_questions(value))
4035
+ if not is_question:
4036
+ for key in ("baseQues", "formQues"):
4037
+ value = payload.get(key)
4038
+ if isinstance(value, list):
4039
+ flattened.extend(_flatten_questions(value))
3954
4040
  elif isinstance(payload, list):
3955
4041
  for item in payload:
3956
4042
  flattened.extend(_flatten_questions(item))
3957
4043
  return flattened
3958
4044
 
3959
4045
 
4046
+ def _should_index_question(question: JSONObject) -> bool:
4047
+ if bool(question.get("beingHide") or question.get("hidden")):
4048
+ return False
4049
+ if _coerce_count(question.get("quoteId")) is not None:
4050
+ return False
4051
+ que_type = _coerce_count(question.get("queType"))
4052
+ if que_type in LAYOUT_ONLY_QUE_TYPES:
4053
+ return False
4054
+ return True
4055
+
4056
+
3960
4057
  def _extract_question_options(question: JSONObject) -> list[str]:
3961
4058
  options = question.get("options")
3962
4059
  if not isinstance(options, list):