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

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.23
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.23 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.23",
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.0b23"
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."
@@ -90,6 +90,14 @@ class ViewSelection:
90
90
  conditions: list[list[ViewFilterCondition]]
91
91
 
92
92
 
93
+ @dataclass(slots=True)
94
+ class WorkflowNodeRef:
95
+ workflow_node_id: int
96
+ name: str
97
+ type: str
98
+ raw: JSONObject
99
+
100
+
93
101
  @dataclass(slots=True)
94
102
  class RecordInputError(Exception):
95
103
  message: str
@@ -134,7 +142,8 @@ FIELD_LOOKUP_STRIP_RE = re.compile(r"[\s_()()\[\]【】{}<>·/\\::-]+")
134
142
  class RecordTools(ToolBase):
135
143
  def __init__(self, sessions, backend) -> None: # type: ignore[no-untyped-def]
136
144
  super().__init__(sessions, backend)
137
- self._form_cache: dict[tuple[str, str], JSONObject] = {}
145
+ self._form_cache: dict[tuple[str, str, str, int], JSONObject] = {}
146
+ self._applicant_node_cache: dict[tuple[str, str], WorkflowNodeRef] = {}
138
147
  self._view_list_cache: dict[tuple[str, str], list[JSONObject]] = {}
139
148
  self._view_config_cache: dict[tuple[str, str], JSONObject] = {}
140
149
 
@@ -286,9 +295,10 @@ class RecordTools(ToolBase):
286
295
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
287
296
 
288
297
  def runner(session_profile, context):
298
+ applicant_node = self._resolve_applicant_node(profile, context, app_key, force_refresh=False)
289
299
  index = self._get_field_index(profile, context, app_key, force_refresh=False)
290
300
  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()]
301
+ fields = [self._schema_field_payload(field, workflow_node_id=applicant_node.workflow_node_id) for field in index.by_id.values()]
292
302
  suggested_dimensions = [
293
303
  {"field_id": item["field_id"], "title": item["title"]}
294
304
  for item in fields
@@ -312,6 +322,12 @@ class RecordTools(ToolBase):
312
322
  "request_route": self._request_route_payload(context),
313
323
  "data": {
314
324
  "app_key": app_key,
325
+ "schema_scope": "applicant_node",
326
+ "workflow_node": {
327
+ "workflow_node_id": applicant_node.workflow_node_id,
328
+ "name": applicant_node.name,
329
+ "type": applicant_node.type,
330
+ },
315
331
  "view_resolution": _view_selection_payload(view_selection),
316
332
  "fields": fields,
317
333
  "suggested_dimensions": suggested_dimensions,
@@ -531,31 +547,39 @@ class RecordTools(ToolBase):
531
547
  }
532
548
  return response
533
549
 
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,
550
+ def runner(session_profile, context):
551
+ index = self._get_field_index(profile, context, app_key, force_refresh=False)
552
+ selected_fields = list(index.by_id.values())
553
+ result = self.backend.request(
554
+ "GET",
555
+ context,
556
+ f"/app/{app_key}/apply/{record_id}",
557
+ params={"role": 1},
558
+ )
559
+ answer_list = result.get("answers") if isinstance(result, dict) and isinstance(result.get("answers"), list) else []
560
+ row = _build_flat_row(cast(list[JSONValue], answer_list), selected_fields, apply_id=record_id)
561
+ response: JSONObject = {
562
+ "profile": profile,
563
+ "ws_id": session_profile.selected_ws_id,
564
+ "ok": True,
565
+ "request_route": self._request_route_payload(context),
566
+ "warnings": [],
567
+ "output_profile": normalized_output_profile,
568
+ "data": {
569
+ "app_key": app_key,
570
+ "record_id": record_id,
571
+ "record": row,
572
+ "selection": {
573
+ "columns": columns,
574
+ "workflow_node_id": workflow_node_id,
575
+ },
556
576
  },
557
- },
558
- }
577
+ }
578
+ if normalized_output_profile == "verbose":
579
+ response["data"]["debug"] = {"raw_record": result}
580
+ return response
581
+
582
+ return self._run_record_tool(profile, runner)
559
583
 
560
584
  def record_write(
561
585
  self,
@@ -712,7 +736,7 @@ class RecordTools(ToolBase):
712
736
  preflight=None,
713
737
  )
714
738
 
715
- def _schema_field_payload(self, field: FormField) -> JSONObject:
739
+ def _schema_field_payload(self, field: FormField, *, workflow_node_id: int) -> JSONObject:
716
740
  write_hints = self._schema_write_hints(field)
717
741
  return {
718
742
  "field_id": field.que_id,
@@ -725,6 +749,8 @@ class RecordTools(ToolBase):
725
749
  "role_hints": self._schema_role_hints(field),
726
750
  "readable": True,
727
751
  "writable": write_hints["writable"],
752
+ "permission_scope": "applicant_node",
753
+ "workflow_node_id": workflow_node_id,
728
754
  "write_kind": write_hints["write_kind"],
729
755
  "supported_read_ops": write_hints["supported_read_ops"],
730
756
  "supported_write_ops": write_hints["supported_write_ops"],
@@ -2390,10 +2416,16 @@ class RecordTools(ToolBase):
2390
2416
  return self._run_record_tool(profile, runner)
2391
2417
 
2392
2418
  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)
2419
+ applicant_node = self._resolve_applicant_node(profile, context, app_key, force_refresh=force_refresh)
2420
+ cache_key = (profile, app_key, "applicant_node", applicant_node.workflow_node_id)
2394
2421
  if not force_refresh and cache_key in self._form_cache:
2395
2422
  return self._form_cache[cache_key]
2396
- schema = self.backend.request("GET", context, f"/app/{app_key}/form", params={"type": 1})
2423
+ schema = self.backend.request(
2424
+ "GET",
2425
+ context,
2426
+ f"/app/{app_key}/form",
2427
+ params={"type": 1, "beingApply": True, "auditNodeId": applicant_node.workflow_node_id},
2428
+ )
2397
2429
  normalized = _normalize_form_schema(schema)
2398
2430
  self._form_cache[cache_key] = normalized
2399
2431
  return normalized
@@ -2401,6 +2433,26 @@ class RecordTools(ToolBase):
2401
2433
  def _get_field_index(self, profile: str, context, app_key: str, *, force_refresh: bool) -> FieldIndex: # type: ignore[no-untyped-def]
2402
2434
  return _build_field_index(self._get_form_schema(profile, context, app_key, force_refresh=force_refresh))
2403
2435
 
2436
+ def _resolve_applicant_node(self, profile: str, context, app_key: str, *, force_refresh: bool) -> WorkflowNodeRef: # type: ignore[no-untyped-def]
2437
+ cache_key = (profile, app_key)
2438
+ if not force_refresh and cache_key in self._applicant_node_cache:
2439
+ return self._applicant_node_cache[cache_key]
2440
+ payload = self.backend.request("GET", context, f"/app/{app_key}/auditNodes")
2441
+ applicant_node = _extract_applicant_node(payload)
2442
+ if applicant_node is None:
2443
+ raise_tool_error(
2444
+ QingflowApiError(
2445
+ category="config",
2446
+ message=f"cannot resolve applicant node for app {app_key}",
2447
+ details={
2448
+ "error_code": "APPLICANT_NODE_NOT_FOUND",
2449
+ "fix_hint": "Ensure the app has a workflow applicant node before using user-side record tools.",
2450
+ },
2451
+ )
2452
+ )
2453
+ self._applicant_node_cache[cache_key] = applicant_node
2454
+ return applicant_node
2455
+
2404
2456
  def _get_view_list(self, profile: str, context, app_key: str) -> list[JSONObject]: # type: ignore[no-untyped-def]
2405
2457
  cache_key = (profile, app_key)
2406
2458
  if cache_key in self._view_list_cache:
@@ -3883,6 +3935,30 @@ def _normalize_view_list(payload: JSONValue) -> list[JSONObject]:
3883
3935
  return flattened
3884
3936
 
3885
3937
 
3938
+ def _normalize_audit_nodes(payload: JSONValue) -> list[JSONObject]:
3939
+ if isinstance(payload, list):
3940
+ return [item for item in payload if isinstance(item, dict)]
3941
+ if isinstance(payload, dict):
3942
+ return [item for item in payload.values() if isinstance(item, dict)]
3943
+ return []
3944
+
3945
+
3946
+ def _extract_applicant_node(payload: JSONValue) -> WorkflowNodeRef | None:
3947
+ for item in _normalize_audit_nodes(payload):
3948
+ node_type = _coerce_count(item.get("type"))
3949
+ deal_type = _coerce_count(item.get("dealType"))
3950
+ workflow_node_id = _coerce_count(item.get("auditNodeId"))
3951
+ if workflow_node_id is None or node_type != 0 or deal_type != 3:
3952
+ continue
3953
+ return WorkflowNodeRef(
3954
+ workflow_node_id=workflow_node_id,
3955
+ name=_normalize_optional_text(item.get("auditNodeName")) or str(workflow_node_id),
3956
+ type="applicant",
3957
+ raw=item,
3958
+ )
3959
+ return None
3960
+
3961
+
3886
3962
  def _compile_view_conditions(config: JSONObject) -> list[list[ViewFilterCondition]]:
3887
3963
  raw_limit = config.get("viewgraphLimit")
3888
3964
  if not isinstance(raw_limit, list):