@josephyan/qingflow-app-builder-mcp 0.2.0-beta.40 → 0.2.0-beta.42

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.40
6
+ npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.42
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.40 qingflow-app-builder-mcp
12
+ npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.42 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.40",
3
+ "version": "0.2.0-beta.42",
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.0b40"
7
+ version = "0.2.0b42"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -26,8 +26,10 @@ dependencies = [
26
26
  "mcp>=1.9.4,<2.0.0",
27
27
  "httpx>=0.27,<1.0",
28
28
  "keyring>=25.5,<26.0",
29
+ "openpyxl>=3.1,<4.0",
29
30
  "pydantic>=2.8,<3.0",
30
31
  "pycryptodome>=3.20,<4.0",
32
+ "python-socketio[client]>=5.11,<6.0",
31
33
  ]
32
34
 
33
35
  [project.optional-dependencies]
@@ -67,6 +67,14 @@ Note:
67
67
  - For flow presets, map natural language to canonical values before calling MCP:
68
68
  - “默认审批/基础审批/普通审批” -> `basic_approval`
69
69
  - “先填报再审批/提交后审批” -> `basic_fill_then_approve`
70
+ - Public flow building is intentionally limited to linear workflows:
71
+ - `start`
72
+ - `approve`
73
+ - `fill`
74
+ - `copy`
75
+ - `webhook`
76
+ - `end`
77
+ Do not generate `branch` or `condition` nodes in the public builder surface. The backend workflow route is not front-end stable for those node types.
70
78
  - For first-time flow or view work in a session, read `builder_tool_contract` before planning so keys, aliases, presets, and minimal examples come from MCP instead of memory.
71
79
  - For workflow assignees, prefer roles over explicit members:
72
80
  - use `role_search` first
@@ -181,6 +189,10 @@ For additive work on existing systems:
181
189
  - All public builder tools expose top-level `warnings`, `verification`, and `verified`. Read them before deciding whether a run is fully done.
182
190
  - For read tools, `status=success` can still pair with `verified=false` when some optional readback is unavailable; in that case prefer `warnings` and `verification` over the bare status code.
183
191
  - For `app_charts_apply`, `portal_apply`, and `app_publish_verify`, treat `success` as “write and verification completed” and `partial_success` as “write executed but verification is incomplete”.
192
+ - For `app_schema_apply`, multiple relation fields are a known high-risk backend area. If you see `RELATION_FIELD_LIMIT_RISK` or `verification.relation_field_limit_verified=false`, do not describe the schema as fully safe.
193
+ - For `app_layout_apply`, trust `verification.layout_verified` first and `verification.layout_summary_verified` second. `LAYOUT_SUMMARY_UNVERIFIED` means the raw form readback is more trustworthy than the compact summary.
194
+ - For `app_views_apply`, treat `verification.views_verified` and `verification.view_filters_verified` separately. A created view with unverified filters is not a finished business view.
195
+ - For `app_flow_apply`, treat only linear node structure as publicly supported. If you see `FLOW_NODE_TYPE_UNSUPPORTED`, redesign the workflow as a stable linear flow instead of retrying branch/condition nodes.
184
196
  - If readback mismatches the UI, compare `request_route` and do not assume the builder hit the same `qf_version` as the browser
185
197
  - Treat post-write readback as the source of truth, not just write status codes
186
198
  - For views, a top-level `VIEW_APPLY_FAILED` does not prove all requested views failed. Read back the view list and verify which views actually landed.
@@ -61,60 +61,16 @@ For flexible business requirements, do not jump straight to a full custom graph.
61
61
  2. identify the business-specific changes
62
62
  3. patch nodes/transitions explicitly
63
63
 
64
- For branch flows, keep the public shape canonical:
64
+ Public flow building is intentionally limited to linear workflows. Use only:
65
65
 
66
- 1. add a `branch` node
67
- 2. add one or more `condition` nodes as branch lanes
68
- 3. put filter rules on the `condition` nodes with `conditions` or `condition_groups`
69
- 4. use an empty `condition` node as the default branch when needed
66
+ - `start`
67
+ - `approve`
68
+ - `fill`
69
+ - `copy`
70
+ - `webhook`
71
+ - `end`
70
72
 
71
- Canonical branch example:
72
-
73
- ```json
74
- {
75
- "tool_name": "app_flow_apply",
76
- "arguments": {
77
- "profile": "default",
78
- "app_key": "APP_123",
79
- "mode": "replace",
80
- "nodes": [
81
- {"id": "start", "type": "start", "name": "发起"},
82
- {"id": "route", "type": "branch", "name": "金额分支"},
83
- {
84
- "id": "high_amount",
85
- "type": "condition",
86
- "name": "金额大于等于一万",
87
- "conditions": [
88
- {"field_name": "预计金额", "operator": "gte", "value": 10000}
89
- ]
90
- },
91
- {
92
- "id": "approve_finance",
93
- "type": "approve",
94
- "name": "财务审批",
95
- "assignees": {"role_names": ["财务负责人"]}
96
- },
97
- {"id": "default_lane", "type": "condition", "name": "其他情况"},
98
- {
99
- "id": "approve_manager",
100
- "type": "approve",
101
- "name": "部门审批",
102
- "assignees": {"role_names": ["项目经理"]}
103
- },
104
- {"id": "end", "type": "end", "name": "结束"}
105
- ],
106
- "transitions": [
107
- {"from": "start", "to": "route"},
108
- {"from": "route", "to": "high_amount"},
109
- {"from": "high_amount", "to": "approve_finance"},
110
- {"from": "route", "to": "default_lane"},
111
- {"from": "default_lane", "to": "approve_manager"},
112
- {"from": "approve_finance", "to": "end"},
113
- {"from": "approve_manager", "to": "end"}
114
- ]
115
- }
116
- }
117
- ```
73
+ Do not generate `branch` or `condition` nodes through `app_flow_apply`. The backend workflow route is not front-end stable for those node types, and MCP now returns `FLOW_NODE_TYPE_UNSUPPORTED` instead of writing a visually broken flow.
118
74
  After `app_flow_apply` returns blocking issues or canonical arguments, prefer reusing its `suggested_next_call.arguments` directly. Do not rewrite the result into internal fields such as `role_entries` or `editable_que_ids`.
119
75
  When you patch a preset, patch the preset node itself. Do not leave the preset approval node unassigned while adding a second custom approval node.
120
76
 
@@ -158,10 +114,13 @@ One or more transitions reference unknown nodes or create an invalid graph.
158
114
  The workflow referenced a field name that does not exist, often in:
159
115
 
160
116
  - `permissions.editable_fields`
161
- - branch `conditions`
162
117
 
163
118
  Call `app_read_fields` and retry with the exact field names returned by the app.
164
119
 
120
+ ### `FLOW_NODE_TYPE_UNSUPPORTED`
121
+
122
+ The public workflow builder only supports linear flows. Remove `branch` and `condition` nodes and redesign the flow with stable sequential nodes instead of retrying the same graph.
123
+
165
124
  ### `STATUS_FIELD_REQUIRED`
166
125
 
167
126
  The app has no explicit status field recognized by the internal workflow compiler. Add one with `app_schema_apply`, then retry.
@@ -2,4 +2,4 @@ from __future__ import annotations
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0b40"
5
+ __version__ = "0.2.0b42"
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
+ from threading import Event
5
+ from typing import Any
6
+ from urllib.parse import urlsplit, urlunsplit
4
7
  from uuid import uuid4
5
8
 
6
9
  import httpx
@@ -201,6 +204,182 @@ class BackendClient:
201
204
  "body": body,
202
205
  }
203
206
 
207
+ def download_binary(self, url: str, *, headers: dict[str, str] | None = None) -> bytes:
208
+ try:
209
+ response = self._client.get(url, headers=headers or None)
210
+ except httpx.RequestError as exc:
211
+ raise QingflowApiError(category="network", message=str(exc))
212
+ if response.status_code >= 400:
213
+ raise QingflowApiError(
214
+ category="http",
215
+ message=self._extract_message(response.text) or f"HTTP {response.status_code}",
216
+ http_status=response.status_code,
217
+ )
218
+ return response.content
219
+
220
+ def request_multipart(
221
+ self,
222
+ method: str,
223
+ context: BackendRequestContext,
224
+ path: str,
225
+ *,
226
+ data: dict[str, Any] | None = None,
227
+ files: dict[str, tuple[str, bytes, str | None]] | None = None,
228
+ unwrap: bool = True,
229
+ ) -> JSONValue:
230
+ return self.request_multipart_with_meta(
231
+ method,
232
+ context,
233
+ path,
234
+ data=data,
235
+ files=files,
236
+ unwrap=unwrap,
237
+ ).data
238
+
239
+ def request_multipart_with_meta(
240
+ self,
241
+ method: str,
242
+ context: BackendRequestContext,
243
+ path: str,
244
+ *,
245
+ data: dict[str, Any] | None = None,
246
+ files: dict[str, tuple[str, bytes, str | None]] | None = None,
247
+ unwrap: bool = True,
248
+ ) -> BackendResponse:
249
+ headers = self._base_headers(
250
+ context.token,
251
+ context.ws_id,
252
+ context.qf_request_id,
253
+ qf_version=context.qf_version,
254
+ )
255
+ request_files: dict[str, tuple[str, bytes, str | None]] | None = None
256
+ if files:
257
+ request_files = {key: value for key, value in files.items()}
258
+ try:
259
+ response = self._client.request(
260
+ method.upper(),
261
+ self._build_url(context.base_url, path),
262
+ data=data,
263
+ files=request_files,
264
+ headers=headers,
265
+ )
266
+ except httpx.RequestError as exc:
267
+ raise QingflowApiError(category="network", message=str(exc), request_id=headers["Qf-Request-Id"])
268
+ parsed = self._parse_response(response, headers["Qf-Request-Id"], unwrap=unwrap)
269
+ return BackendResponse(
270
+ data=parsed,
271
+ headers=dict(response.headers),
272
+ request_id=headers["Qf-Request-Id"],
273
+ http_status=response.status_code,
274
+ qf_response_version=self._extract_response_qf_version(response.headers),
275
+ )
276
+
277
+ def start_socket_data_import(
278
+ self,
279
+ context: BackendRequestContext,
280
+ *,
281
+ app_key: str,
282
+ being_enter_auditing: bool,
283
+ view_key: str | None,
284
+ excel_url: str,
285
+ excel_name: str,
286
+ ack_timeout_seconds: float = 8.0,
287
+ initial_wait_seconds: float = 4.0,
288
+ ) -> dict[str, Any]:
289
+ try:
290
+ import socketio # type: ignore[import-not-found]
291
+ except ImportError as exc:
292
+ raise QingflowApiError(
293
+ category="config",
294
+ message=f"socket.io client dependency is missing: {exc}",
295
+ )
296
+
297
+ socket_base_url = self._build_socket_base_url(context.base_url)
298
+ import_result: dict[str, Any] = {
299
+ "import_id": None,
300
+ "process_id_str": None,
301
+ "status": "accepted",
302
+ "warnings": [],
303
+ "initial_event": None,
304
+ "failure_event": None,
305
+ }
306
+ initial_event_received = Event()
307
+ failure_event_received = Event()
308
+ sio = socketio.Client(reconnection=False, logger=False, engineio_logger=False)
309
+
310
+ def _handle_initial(payload: Any) -> None:
311
+ if isinstance(payload, dict):
312
+ import_result["initial_event"] = payload
313
+ process_id = payload.get("processIdStr") or payload.get("process_id_str") or payload.get("processId")
314
+ if process_id is not None:
315
+ import_result["process_id_str"] = str(process_id)
316
+ initial_event_received.set()
317
+
318
+ def _handle_failure(payload: Any) -> None:
319
+ if isinstance(payload, dict):
320
+ import_result["failure_event"] = payload
321
+ process_id = payload.get("processIdStr") or payload.get("process_id_str") or payload.get("processId")
322
+ if process_id is not None:
323
+ import_result["process_id_str"] = str(process_id)
324
+ failure_event_received.set()
325
+
326
+ try:
327
+ sio.connect(
328
+ socket_base_url,
329
+ transports=["polling", "websocket"],
330
+ socketio_path="socket.io",
331
+ headers=self._base_headers(
332
+ context.token,
333
+ context.ws_id,
334
+ qf_version=context.qf_version,
335
+ ),
336
+ wait_timeout=ack_timeout_seconds,
337
+ )
338
+ ack = sio.call(
339
+ "dataImport",
340
+ [
341
+ context.token,
342
+ app_key,
343
+ bool(being_enter_auditing),
344
+ view_key,
345
+ False,
346
+ excel_url,
347
+ excel_name,
348
+ ],
349
+ timeout=ack_timeout_seconds,
350
+ )
351
+ import_id = ack[0] if isinstance(ack, list) and ack else ack
352
+ if not import_id:
353
+ raise QingflowApiError(category="backend", message="socket import ack did not return import_id")
354
+ import_result["import_id"] = str(import_id)
355
+ sio.on(f"dataImportRes_{import_result['import_id']}", _handle_initial)
356
+ sio.on(f"dataImportFail_{import_result['import_id']}", _handle_failure)
357
+ if not initial_event_received.wait(timeout=initial_wait_seconds) and not failure_event_received.wait(timeout=0.1):
358
+ import_result["warnings"].append(
359
+ {
360
+ "code": "IMPORT_SOCKET_INITIAL_EVENT_PENDING",
361
+ "message": "Import ack received, but no initial progress payload arrived within the initial wait window.",
362
+ }
363
+ )
364
+ except Exception as exc:
365
+ message = str(exc)
366
+ if "timeout" in message.lower():
367
+ raise QingflowApiError(
368
+ category="network",
369
+ message="socket import ack timed out",
370
+ details={"error_code": "IMPORT_SOCKET_ACK_TIMEOUT"},
371
+ )
372
+ if isinstance(exc, QingflowApiError):
373
+ raise
374
+ raise QingflowApiError(category="network", message=message or "socket import failed")
375
+ finally:
376
+ try:
377
+ if sio.connected:
378
+ sio.disconnect()
379
+ except Exception:
380
+ pass
381
+ return import_result
382
+
204
383
  def _request_with_meta(
205
384
  self,
206
385
  method: str,
@@ -334,3 +513,13 @@ class BackendClient:
334
513
  if not normalized:
335
514
  raise QingflowApiError.config_error("base_url is required")
336
515
  return f"{normalized}/{path.lstrip('/')}"
516
+
517
+ def _build_socket_base_url(self, base_url: str) -> str:
518
+ normalized = normalize_base_url(base_url)
519
+ if not normalized:
520
+ raise QingflowApiError.config_error("base_url is required")
521
+ parsed = urlsplit(normalized)
522
+ path = parsed.path.rstrip("/")
523
+ if path.endswith("/api"):
524
+ path = path[:-4]
525
+ return urlunsplit((parsed.scheme, parsed.netloc, path or "", "", ""))
@@ -266,6 +266,8 @@ class FieldPatch(StrictModel):
266
266
  description: str | None = None
267
267
  options: list[str] = Field(default_factory=list)
268
268
  target_app_key: str | None = None
269
+ display_field: FieldSelector | None = None
270
+ visible_fields: list[FieldSelector] = Field(default_factory=list)
269
271
  subfields: list["FieldPatch"] = Field(default_factory=list)
270
272
 
271
273
  @model_validator(mode="after")
@@ -274,6 +276,8 @@ class FieldPatch(StrictModel):
274
276
  raise ValueError("relation field requires target_app_key")
275
277
  if self.type != PublicFieldType.relation and self.target_app_key:
276
278
  raise ValueError("target_app_key is only allowed for relation fields")
279
+ if self.type != PublicFieldType.relation and (self.display_field is not None or self.visible_fields):
280
+ raise ValueError("display_field and visible_fields are only allowed for relation fields")
277
281
  if self.type == PublicFieldType.subtable and not self.subfields:
278
282
  raise ValueError("subtable field requires subfields")
279
283
  if self.type != PublicFieldType.subtable and self.subfields:
@@ -293,12 +297,16 @@ class FieldMutation(StrictModel):
293
297
  description: str | None = None
294
298
  options: list[str] | None = None
295
299
  target_app_key: str | None = None
300
+ display_field: FieldSelector | None = None
301
+ visible_fields: list[FieldSelector] | None = None
296
302
  subfields: list[FieldPatch] | None = None
297
303
 
298
304
  @model_validator(mode="after")
299
305
  def validate_shape(self) -> "FieldMutation":
300
306
  if self.type == PublicFieldType.relation and not self.target_app_key:
301
307
  raise ValueError("relation field requires target_app_key")
308
+ if self.type is not None and self.type != PublicFieldType.relation and (self.display_field is not None or self.visible_fields):
309
+ raise ValueError("display_field and visible_fields are only allowed for relation fields")
302
310
  if self.type == PublicFieldType.subtable and not self.subfields:
303
311
  raise ValueError("subtable field requires subfields")
304
312
  return self