@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 +2 -2
- package/package.json +1 -1
- package/pyproject.toml +3 -1
- package/skills/qingflow-app-builder/SKILL.md +12 -0
- package/skills/qingflow-app-builder/references/update-flow.md +12 -53
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/backend_client.py +189 -0
- package/src/qingflow_mcp/builder_facade/models.py +8 -0
- package/src/qingflow_mcp/builder_facade/service.py +554 -53
- package/src/qingflow_mcp/import_store.py +121 -0
- package/src/qingflow_mcp/server.py +11 -0
- package/src/qingflow_mcp/server_app_builder.py +1 -1
- package/src/qingflow_mcp/server_app_user.py +13 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +13 -3
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +20 -0
- package/src/qingflow_mcp/solution/executor.py +10 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +22 -95
- package/src/qingflow_mcp/tools/import_tools.py +1207 -0
- package/src/qingflow_mcp/tools/record_tools.py +114 -7
- package/src/qingflow_mcp/tools/task_context_tools.py +318 -46
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.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.
|
|
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
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.
|
|
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
|
-
|
|
64
|
+
Public flow building is intentionally limited to linear workflows. Use only:
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
- `start`
|
|
67
|
+
- `approve`
|
|
68
|
+
- `fill`
|
|
69
|
+
- `copy`
|
|
70
|
+
- `webhook`
|
|
71
|
+
- `end`
|
|
70
72
|
|
|
71
|
-
|
|
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.
|
|
@@ -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
|