@qingflow-tech/qingflow-app-user-mcp 1.0.1 → 1.0.3
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/docs/local-agent-install.md +9 -3
- package/npm/lib/runtime.mjs +10 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-user/SKILL.md +21 -12
- package/skills/qingflow-app-user/references/data-gotchas.md +1 -1
- package/skills/qingflow-app-user/references/public-surface-sync.md +70 -0
- package/skills/qingflow-app-user/references/record-patterns.md +1 -1
- package/skills/qingflow-record-analysis/SKILL.md +44 -2
- package/skills/qingflow-record-insert/SKILL.md +3 -0
- package/skills/qingflow-record-update/SKILL.md +3 -0
- package/skills/qingflow-task-ops/SKILL.md +31 -10
- package/src/qingflow_mcp/__init__.py +33 -1
- package/src/qingflow_mcp/builder_facade/models.py +14 -4
- package/src/qingflow_mcp/builder_facade/service.py +1582 -124
- package/src/qingflow_mcp/cli/commands/auth.py +69 -1
- package/src/qingflow_mcp/cli/commands/builder.py +4 -3
- package/src/qingflow_mcp/cli/commands/record.py +5 -5
- package/src/qingflow_mcp/cli/commands/task.py +74 -22
- package/src/qingflow_mcp/cli/commands/workspace.py +22 -0
- package/src/qingflow_mcp/cli/formatters.py +287 -48
- package/src/qingflow_mcp/cli/main.py +6 -1
- package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
- package/src/qingflow_mcp/config.py +8 -0
- package/src/qingflow_mcp/errors.py +2 -2
- package/src/qingflow_mcp/id_utils.py +49 -0
- package/src/qingflow_mcp/public_surface.py +11 -1
- package/src/qingflow_mcp/response_trim.py +380 -9
- package/src/qingflow_mcp/server.py +4 -0
- package/src/qingflow_mcp/server_app_builder.py +11 -1
- package/src/qingflow_mcp/server_app_user.py +24 -0
- package/src/qingflow_mcp/session_store.py +69 -15
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +2 -2
- package/src/qingflow_mcp/solution/executor.py +2 -2
- package/src/qingflow_mcp/tools/ai_builder_tools.py +48 -18
- package/src/qingflow_mcp/tools/app_tools.py +1 -0
- package/src/qingflow_mcp/tools/auth_tools.py +271 -12
- package/src/qingflow_mcp/tools/base.py +6 -2
- package/src/qingflow_mcp/tools/code_block_tools.py +2 -2
- package/src/qingflow_mcp/tools/import_tools.py +36 -2
- package/src/qingflow_mcp/tools/record_tools.py +410 -156
- package/src/qingflow_mcp/tools/resource_read_tools.py +114 -32
- package/src/qingflow_mcp/tools/task_context_tools.py +899 -141
- package/src/qingflow_mcp/tools/workspace_tools.py +141 -0
|
@@ -38,8 +38,19 @@ def _format_whoami(result: dict[str, Any]) -> str:
|
|
|
38
38
|
f"User: {result.get('nick_name') or '-'} ({result.get('email') or '-'})",
|
|
39
39
|
f"UID: {result.get('uid')}",
|
|
40
40
|
f"Workspace: {result.get('selected_ws_name') or '-'} ({result.get('selected_ws_id') or '-'})",
|
|
41
|
-
f"QF Version: {result.get('qf_version') or '-'}",
|
|
41
|
+
f"Workspace QF Version: {result.get('qf_version') or '-'}",
|
|
42
42
|
]
|
|
43
|
+
cli_auth = result.get("cli_auth") if isinstance(result.get("cli_auth"), dict) else {}
|
|
44
|
+
if cli_auth:
|
|
45
|
+
lines.append(f"Login Flow: {cli_auth.get('flow') or '-'}")
|
|
46
|
+
if cli_auth.get("verification_uri"):
|
|
47
|
+
lines.append(f"Verification URL: {cli_auth.get('verification_uri')}")
|
|
48
|
+
if cli_auth.get("user_code"):
|
|
49
|
+
lines.append(f"User Code: {cli_auth.get('user_code')}")
|
|
50
|
+
request_route = result.get("request_route") if isinstance(result.get("request_route"), dict) else {}
|
|
51
|
+
route_qf_version = request_route.get("qf_version")
|
|
52
|
+
if route_qf_version and route_qf_version != result.get("qf_version"):
|
|
53
|
+
lines.append(f"Request Route QF Version: {route_qf_version}")
|
|
43
54
|
lines.append(f"Permission Level: {result.get('permission_level') or '-'}")
|
|
44
55
|
departments = result.get("departments") if isinstance(result.get("departments"), list) else []
|
|
45
56
|
roles = result.get("roles") if isinstance(result.get("roles"), list) else []
|
|
@@ -82,6 +93,19 @@ def _format_workspace_list(result: dict[str, Any]) -> str:
|
|
|
82
93
|
return _render_titled_table("Workspaces", ["ws_id", "name", "remark"], rows)
|
|
83
94
|
|
|
84
95
|
|
|
96
|
+
def _format_workspace_get(result: dict[str, Any]) -> str:
|
|
97
|
+
workspace = result.get("workspace") if isinstance(result.get("workspace"), dict) else {}
|
|
98
|
+
lines = [
|
|
99
|
+
f"Workspace: {workspace.get('workspaceName') or workspace.get('wsName') or '-'} ({workspace.get('wsId') or result.get('ws_id') or '-'})",
|
|
100
|
+
f"QF Version: {result.get('qf_version') or workspace.get('systemVersion') or '-'}",
|
|
101
|
+
f"Identity: {workspace.get('identity') or '-'}",
|
|
102
|
+
f"Auth: {workspace.get('auth') if workspace.get('auth') is not None else '-'}",
|
|
103
|
+
f"State: {workspace.get('state') if workspace.get('state') is not None else '-'}",
|
|
104
|
+
]
|
|
105
|
+
_append_warnings(lines, result.get("warnings"))
|
|
106
|
+
return "\n".join(lines) + "\n"
|
|
107
|
+
|
|
108
|
+
|
|
85
109
|
def _format_app_items(result: dict[str, Any]) -> str:
|
|
86
110
|
items = result.get("items")
|
|
87
111
|
if not isinstance(items, list):
|
|
@@ -118,6 +142,7 @@ def _format_app_get(result: dict[str, Any]) -> str:
|
|
|
118
142
|
if editability:
|
|
119
143
|
lines.append(
|
|
120
144
|
"Editability: "
|
|
145
|
+
f"app_base={editability.get('can_edit_app_base')} / "
|
|
121
146
|
f"form={editability.get('can_edit_form')} / "
|
|
122
147
|
f"flow={editability.get('can_edit_flow')} / "
|
|
123
148
|
f"views={editability.get('can_edit_views')} / "
|
|
@@ -148,20 +173,24 @@ def _format_record_list(result: dict[str, Any]) -> str:
|
|
|
148
173
|
def _format_task_list(result: dict[str, Any]) -> str:
|
|
149
174
|
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
|
150
175
|
items = data.get("items") if isinstance(data.get("items"), list) else []
|
|
151
|
-
|
|
176
|
+
lines = ["Tasks"]
|
|
152
177
|
for item in items:
|
|
153
178
|
if not isinstance(item, dict):
|
|
154
179
|
continue
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
180
|
+
header_parts = [
|
|
181
|
+
str(item.get("task_id") or "-"),
|
|
182
|
+
str(item.get("app_name") or item.get("app_key") or "-"),
|
|
183
|
+
str(item.get("workflow_node_name") or "-"),
|
|
184
|
+
]
|
|
185
|
+
apply_time = item.get("apply_time")
|
|
186
|
+
if apply_time not in (None, ""):
|
|
187
|
+
header_parts.append(str(apply_time))
|
|
188
|
+
lines.append("- " + " / ".join(header_parts))
|
|
189
|
+
summary_fields = item.get("summary_fields") if isinstance(item.get("summary_fields"), list) else []
|
|
190
|
+
for summary in summary_fields:
|
|
191
|
+
if not isinstance(summary, dict):
|
|
192
|
+
continue
|
|
193
|
+
lines.append(f" {summary.get('title') or '-'}: {summary.get('answer') or '-'}")
|
|
165
194
|
_append_warnings(lines, result.get("warnings"))
|
|
166
195
|
return "\n".join(lines) + "\n"
|
|
167
196
|
|
|
@@ -169,34 +198,119 @@ def _format_task_list(result: dict[str, Any]) -> str:
|
|
|
169
198
|
def _format_task_get(result: dict[str, Any]) -> str:
|
|
170
199
|
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
|
171
200
|
task = data.get("task") if isinstance(data.get("task"), dict) else {}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
f"
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
201
|
+
record_summary = data.get("record_summary") if isinstance(data.get("record_summary"), dict) else {}
|
|
202
|
+
editable_fields = data.get("editable_fields") if isinstance(data.get("editable_fields"), list) else []
|
|
203
|
+
available_actions = data.get("available_actions") if isinstance(data.get("available_actions"), list) else []
|
|
204
|
+
extras = data.get("extras") if isinstance(data.get("extras"), dict) else {}
|
|
205
|
+
initiator = task.get("initiator") if isinstance(task.get("initiator"), dict) else {}
|
|
206
|
+
initiator_label = initiator.get("displayName") or initiator.get("email") or "-"
|
|
207
|
+
lines = []
|
|
208
|
+
if task.get("task_id") not in (None, ""):
|
|
209
|
+
lines.append(f"Task ID: {task.get('task_id')}")
|
|
210
|
+
lines.extend(
|
|
211
|
+
[
|
|
212
|
+
f"Locator: {task.get('app_key') or '-'} / {task.get('record_id') or '-'} / {task.get('workflow_node_id') or '-'}",
|
|
213
|
+
f"Node: {task.get('workflow_node_name') or '-'}",
|
|
214
|
+
f"App: {task.get('app_name') or '-'}",
|
|
215
|
+
f"Initiator: {initiator_label}",
|
|
216
|
+
f"Apply Status: {record_summary.get('apply_status')}",
|
|
217
|
+
f"Available Actions: {', '.join(str(item) for item in available_actions) or '-'}",
|
|
218
|
+
f"Editable Fields: {len(editable_fields)}",
|
|
219
|
+
]
|
|
220
|
+
)
|
|
221
|
+
core_fields = record_summary.get("core_fields") if isinstance(record_summary.get("core_fields"), dict) else {}
|
|
222
|
+
if core_fields:
|
|
223
|
+
lines.append("Core Fields:")
|
|
224
|
+
for key, value in list(core_fields.items())[:12]:
|
|
225
|
+
lines.append(f"- {key}: {value}")
|
|
226
|
+
if editable_fields:
|
|
227
|
+
lines.append("Editable Fields:")
|
|
228
|
+
for item in editable_fields[:10]:
|
|
185
229
|
if isinstance(item, dict):
|
|
186
230
|
lines.append(f"- {item.get('title') or '-'} ({item.get('kind') or 'field'})")
|
|
187
|
-
|
|
188
|
-
if
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
231
|
+
associated_reports = extras.get("associated_reports") if isinstance(extras.get("associated_reports"), dict) else {}
|
|
232
|
+
rollback_candidates = extras.get("rollback_candidates") if isinstance(extras.get("rollback_candidates"), dict) else {}
|
|
233
|
+
transfer_candidates = extras.get("transfer_candidates") if isinstance(extras.get("transfer_candidates"), dict) else {}
|
|
234
|
+
lines.append(
|
|
235
|
+
"Extras: "
|
|
236
|
+
f"reports={associated_reports.get('count', 0)}, "
|
|
237
|
+
f"rollback={rollback_candidates.get('count', 0)}, "
|
|
238
|
+
f"transfer={transfer_candidates.get('count', 0)}"
|
|
239
|
+
)
|
|
240
|
+
transfer_items = transfer_candidates.get("items") if isinstance(transfer_candidates.get("items"), list) else []
|
|
241
|
+
if transfer_items:
|
|
242
|
+
lines.append("Transfer Candidates:")
|
|
243
|
+
for item in transfer_items:
|
|
196
244
|
if isinstance(item, dict):
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
lines.append(f"- {item}")
|
|
245
|
+
display = item.get("name") or item.get("uid") or item
|
|
246
|
+
suffix = f" <{item.get('email')}>" if item.get("email") else ""
|
|
247
|
+
lines.append(f"- {display}{suffix} (uid={item.get('uid') or '-'})")
|
|
248
|
+
_append_warnings(lines, result.get("warnings"))
|
|
249
|
+
return "\n".join(lines) + "\n"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _format_task_action(result: dict[str, Any]) -> str:
|
|
253
|
+
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
|
254
|
+
action = str(data.get("action") or "").strip().lower()
|
|
255
|
+
status = str(result.get("status") or "").strip().lower()
|
|
256
|
+
|
|
257
|
+
if status == "failed" or result.get("ok") is False:
|
|
258
|
+
lines = [_task_action_failure_label(action)]
|
|
259
|
+
reason = _task_action_failure_reason(result)
|
|
260
|
+
if reason:
|
|
261
|
+
lines.append(f"原因:{reason}")
|
|
262
|
+
debug_lines = _task_action_debug_lines(result)
|
|
263
|
+
if debug_lines:
|
|
264
|
+
lines.append("调试信息:")
|
|
265
|
+
lines.extend(f"- {line}" for line in debug_lines)
|
|
266
|
+
return "\n".join(lines) + "\n"
|
|
267
|
+
|
|
268
|
+
if status == "partial_success":
|
|
269
|
+
lines = [_task_action_success_label(action)]
|
|
270
|
+
lines.append(f"说明:{_task_action_partial_success_message(result)}")
|
|
271
|
+
return "\n".join(lines) + "\n"
|
|
272
|
+
|
|
273
|
+
return _task_action_success_label(action) + "\n"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _format_task_associated_report_detail(result: dict[str, Any]) -> str:
|
|
277
|
+
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
|
278
|
+
selection = data.get("selection") if isinstance(data.get("selection"), dict) else {}
|
|
279
|
+
result_type = str(data.get("result_type") or "-")
|
|
280
|
+
context = data.get("context") if isinstance(data.get("context"), dict) else {}
|
|
281
|
+
lines = []
|
|
282
|
+
if selection.get("task_id") not in (None, ""):
|
|
283
|
+
lines.append(f"Task ID: {selection.get('task_id')}")
|
|
284
|
+
lines.extend(
|
|
285
|
+
[
|
|
286
|
+
f"Report: {selection.get('chart_name') or '-'} ({selection.get('report_id') or '-'})",
|
|
287
|
+
f"Type: {result_type}",
|
|
288
|
+
]
|
|
289
|
+
)
|
|
290
|
+
if result_type == "view_list":
|
|
291
|
+
result_payload = data.get("result") if isinstance(data.get("result"), dict) else {}
|
|
292
|
+
items = result_payload.get("items") if isinstance(result_payload.get("items"), list) else []
|
|
293
|
+
lines.append(f"Returned Records: {len(items)}")
|
|
294
|
+
for item in items[:10]:
|
|
295
|
+
if isinstance(item, dict):
|
|
296
|
+
lines.append(json.dumps(item, ensure_ascii=False))
|
|
297
|
+
if len(items) > 10:
|
|
298
|
+
lines.append(f"... {len(items) - 10} more")
|
|
299
|
+
elif result_type == "chart_data":
|
|
300
|
+
result_payload = data.get("result") if isinstance(data.get("result"), dict) else {}
|
|
301
|
+
summary = result_payload.get("summary") if isinstance(result_payload.get("summary"), dict) else {}
|
|
302
|
+
rows = result_payload.get("rows") if isinstance(result_payload.get("rows"), list) else []
|
|
303
|
+
if summary:
|
|
304
|
+
lines.append(f"Summary: {json.dumps(summary, ensure_ascii=False)}")
|
|
305
|
+
lines.append(f"Rows: {len(rows)}")
|
|
306
|
+
for row in rows[:10]:
|
|
307
|
+
if isinstance(row, dict):
|
|
308
|
+
lines.append(json.dumps(row, ensure_ascii=False))
|
|
309
|
+
if len(rows) > 10:
|
|
310
|
+
lines.append(f"... {len(rows) - 10} more")
|
|
311
|
+
resolved_filters = context.get("resolved_filters") if isinstance(context.get("resolved_filters"), list) else []
|
|
312
|
+
if resolved_filters:
|
|
313
|
+
lines.append(f"Resolved Filters: {len(resolved_filters)}")
|
|
200
314
|
_append_warnings(lines, result.get("warnings"))
|
|
201
315
|
return "\n".join(lines) + "\n"
|
|
202
316
|
|
|
@@ -206,17 +320,22 @@ def _format_import_verify(result: dict[str, Any]) -> str:
|
|
|
206
320
|
f"App Key: {result.get('app_key') or '-'}",
|
|
207
321
|
f"File: {result.get('file_name') or result.get('file_path') or '-'}",
|
|
208
322
|
f"Can Import: {result.get('can_import')}",
|
|
209
|
-
f"Apply Rows: {result.get('apply_rows')}",
|
|
210
323
|
f"Verification ID: {result.get('verification_id') or '-'}",
|
|
211
324
|
]
|
|
212
|
-
|
|
213
|
-
if
|
|
214
|
-
lines.append(
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
325
|
+
issue_summary = result.get("issue_summary") if isinstance(result.get("issue_summary"), dict) else {}
|
|
326
|
+
if issue_summary:
|
|
327
|
+
lines.append(
|
|
328
|
+
"Issues: "
|
|
329
|
+
f"total={issue_summary.get('total', 0)}, "
|
|
330
|
+
f"errors={issue_summary.get('errors', 0)}, "
|
|
331
|
+
f"warnings={issue_summary.get('warnings', 0)}"
|
|
332
|
+
)
|
|
333
|
+
sample = issue_summary.get("sample") if isinstance(issue_summary.get("sample"), list) else []
|
|
334
|
+
if sample:
|
|
335
|
+
lines.append("Issue Samples:")
|
|
336
|
+
for item in sample:
|
|
337
|
+
if isinstance(item, dict):
|
|
338
|
+
lines.append(f"- {item.get('code') or 'ISSUE'}: {item.get('message') or ''}".rstrip())
|
|
220
339
|
_append_warnings(lines, result.get("warnings"))
|
|
221
340
|
_append_verification(lines, result.get("verification"))
|
|
222
341
|
return "\n".join(lines) + "\n"
|
|
@@ -227,8 +346,10 @@ def _format_import_status(result: dict[str, Any]) -> str:
|
|
|
227
346
|
f"Status: {result.get('status') or '-'}",
|
|
228
347
|
f"Import ID: {result.get('import_id') or '-'}",
|
|
229
348
|
f"Process ID: {result.get('process_id_str') or '-'}",
|
|
230
|
-
f"
|
|
231
|
-
f"
|
|
349
|
+
f"Total Rows: {result.get('total') or 0}",
|
|
350
|
+
f"Finished Rows: {result.get('finished') or 0}",
|
|
351
|
+
f"Succeeded Rows: {result.get('succeeded') or 0}",
|
|
352
|
+
f"Failed Rows: {result.get('failed') or 0}",
|
|
232
353
|
f"Progress: {result.get('progress') or '-'}",
|
|
233
354
|
]
|
|
234
355
|
_append_warnings(lines, result.get("warnings"))
|
|
@@ -319,15 +440,133 @@ def _first_present(payload: dict[str, Any], *keys: str) -> Any:
|
|
|
319
440
|
return None
|
|
320
441
|
|
|
321
442
|
|
|
443
|
+
def _task_action_success_label(action: str) -> str:
|
|
444
|
+
return {
|
|
445
|
+
"approve": "已通过",
|
|
446
|
+
"reject": "已驳回",
|
|
447
|
+
"rollback": "已退回",
|
|
448
|
+
"transfer": "已转交",
|
|
449
|
+
"save_only": "已保存",
|
|
450
|
+
"urge": "已催办",
|
|
451
|
+
}.get(action, "已执行")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _task_action_failure_label(action: str) -> str:
|
|
455
|
+
return {
|
|
456
|
+
"approve": "审批失败",
|
|
457
|
+
"reject": "驳回失败",
|
|
458
|
+
"rollback": "退回失败",
|
|
459
|
+
"transfer": "转交失败",
|
|
460
|
+
"save_only": "保存失败",
|
|
461
|
+
"urge": "催办失败",
|
|
462
|
+
}.get(action, "执行失败")
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _task_action_partial_success_message(result: dict[str, Any]) -> str:
|
|
466
|
+
error_code = str(result.get("error_code") or "").strip().upper()
|
|
467
|
+
if error_code == "WORKFLOW_CONTINUATION_UNVERIFIED":
|
|
468
|
+
return "动作已提交,但暂未完成后续流程验证。可使用 --json 查看详细信息。"
|
|
469
|
+
if error_code == "TASK_ALREADY_PROCESSED":
|
|
470
|
+
return "当前待办已不可操作,系统判断流程可能已被其他人处理。可使用 --json 查看详细信息。"
|
|
471
|
+
warnings = result.get("warnings")
|
|
472
|
+
if isinstance(warnings, list):
|
|
473
|
+
for warning in warnings:
|
|
474
|
+
if not isinstance(warning, dict):
|
|
475
|
+
continue
|
|
476
|
+
code = str(warning.get("code") or "").strip().upper()
|
|
477
|
+
if code == "TASK_ALREADY_PROCESSED_UNCONFIRMED_ACTOR":
|
|
478
|
+
return "当前待办已不可操作,系统判断流程可能已被其他人处理。可使用 --json 查看详细信息。"
|
|
479
|
+
if code == "WORKFLOW_CONTINUATION_UNVERIFIED":
|
|
480
|
+
return "动作已提交,但暂未完成后续流程验证。可使用 --json 查看详细信息。"
|
|
481
|
+
return "动作已提交,但结果验证不完整。可使用 --json 查看详细信息。"
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _task_action_failure_reason(result: dict[str, Any]) -> str | None:
|
|
485
|
+
error_code = str(result.get("error_code") or "").strip().upper()
|
|
486
|
+
mapped_error = {
|
|
487
|
+
"TASK_CONTEXT_VISIBILITY_UNVERIFIED": "当前待办已不可操作,且系统未能确认是否已被处理。",
|
|
488
|
+
"TASK_SAVE_ONLY_VERIFICATION_FAILED": "保存请求已发送,但未能确认字段是否全部保存成功。",
|
|
489
|
+
"WORKFLOW_CONTINUATION_UNVERIFIED": "动作已提交,但暂未验证到流程继续推进。",
|
|
490
|
+
"TASK_ALREADY_PROCESSED": "当前待办已不可操作,系统判断流程可能已被其他人处理。",
|
|
491
|
+
}.get(error_code)
|
|
492
|
+
if mapped_error:
|
|
493
|
+
return mapped_error
|
|
494
|
+
|
|
495
|
+
warnings = result.get("warnings")
|
|
496
|
+
if isinstance(warnings, list):
|
|
497
|
+
for warning in warnings:
|
|
498
|
+
if isinstance(warning, dict) and warning.get("message"):
|
|
499
|
+
return str(warning.get("message"))
|
|
500
|
+
|
|
501
|
+
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
|
502
|
+
transport_error = data.get("transport_error") if isinstance(data.get("transport_error"), dict) else {}
|
|
503
|
+
backend_code = transport_error.get("backend_code")
|
|
504
|
+
http_status = transport_error.get("http_status")
|
|
505
|
+
if backend_code not in (None, ""):
|
|
506
|
+
return f"后端返回错误码 {backend_code}。"
|
|
507
|
+
if http_status not in (None, ""):
|
|
508
|
+
return f"请求返回 HTTP {http_status}。"
|
|
509
|
+
|
|
510
|
+
verification = result.get("verification") if isinstance(result.get("verification"), dict) else {}
|
|
511
|
+
record_state_error = verification.get("record_state_error") if isinstance(verification.get("record_state_error"), dict) else {}
|
|
512
|
+
backend_code = record_state_error.get("backend_code")
|
|
513
|
+
http_status = record_state_error.get("http_status")
|
|
514
|
+
if backend_code not in (None, ""):
|
|
515
|
+
return f"后端返回错误码 {backend_code}。"
|
|
516
|
+
if http_status not in (None, ""):
|
|
517
|
+
return f"请求返回 HTTP {http_status}。"
|
|
518
|
+
if error_code:
|
|
519
|
+
return f"错误码:{error_code}"
|
|
520
|
+
return None
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _task_action_debug_lines(result: dict[str, Any]) -> list[str]:
|
|
524
|
+
lines: list[str] = []
|
|
525
|
+
error_code = result.get("error_code")
|
|
526
|
+
if error_code not in (None, ""):
|
|
527
|
+
lines.append(f"error_code: {error_code}")
|
|
528
|
+
|
|
529
|
+
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
|
530
|
+
transport_error = data.get("transport_error") if isinstance(data.get("transport_error"), dict) else {}
|
|
531
|
+
for key in ("backend_code", "http_status", "category"):
|
|
532
|
+
value = transport_error.get(key)
|
|
533
|
+
if value not in (None, ""):
|
|
534
|
+
lines.append(f"{key}: {value}")
|
|
535
|
+
|
|
536
|
+
verification = result.get("verification") if isinstance(result.get("verification"), dict) else {}
|
|
537
|
+
for key in (
|
|
538
|
+
"runtime_continuation_verified",
|
|
539
|
+
"task_context_visibility_verified",
|
|
540
|
+
"fields_saved_verified",
|
|
541
|
+
"task_still_actionable",
|
|
542
|
+
"workflow_not_advanced",
|
|
543
|
+
"record_state_readable",
|
|
544
|
+
):
|
|
545
|
+
if key in verification and verification.get(key) is not None:
|
|
546
|
+
lines.append(f"{key}: {verification.get(key)}")
|
|
547
|
+
|
|
548
|
+
record_state_error = verification.get("record_state_error") if isinstance(verification.get("record_state_error"), dict) else {}
|
|
549
|
+
for key in ("backend_code", "http_status", "category"):
|
|
550
|
+
value = record_state_error.get(key)
|
|
551
|
+
if value not in (None, ""):
|
|
552
|
+
entry = f"record_state_{key}: {value}"
|
|
553
|
+
if entry not in lines:
|
|
554
|
+
lines.append(entry)
|
|
555
|
+
return lines
|
|
556
|
+
|
|
557
|
+
|
|
322
558
|
_FORMATTERS = {
|
|
323
559
|
"auth_whoami": _format_whoami,
|
|
324
560
|
"workspace_list": _format_workspace_list,
|
|
561
|
+
"workspace_get": _format_workspace_get,
|
|
325
562
|
"app_list": _format_app_items,
|
|
326
563
|
"app_search": _format_app_items,
|
|
327
564
|
"app_get": _format_app_get,
|
|
328
565
|
"record_list": _format_record_list,
|
|
329
566
|
"task_list": _format_task_list,
|
|
330
567
|
"task_get": _format_task_get,
|
|
568
|
+
"task_action_execute": _format_task_action,
|
|
569
|
+
"task_associated_report_detail_get": _format_task_associated_report_detail,
|
|
331
570
|
"import_verify": _format_import_verify,
|
|
332
571
|
"import_status": _format_import_status,
|
|
333
572
|
"builder_summary": _format_builder_summary,
|
|
@@ -152,6 +152,7 @@ def _emit_cli_effective_context_notice(args: argparse.Namespace, context: CliCon
|
|
|
152
152
|
spec = cli_public_tool_spec_from_namespace(args)
|
|
153
153
|
if spec is None or not spec.cli_show_effective_context:
|
|
154
154
|
return
|
|
155
|
+
hide_context_line = bool(getattr(args, "hide_effective_context_line", False))
|
|
155
156
|
sessions = getattr(context, "sessions", None)
|
|
156
157
|
if sessions is None or not hasattr(sessions, "get_profile"):
|
|
157
158
|
return
|
|
@@ -168,9 +169,13 @@ def _emit_cli_effective_context_notice(args: argparse.Namespace, context: CliCon
|
|
|
168
169
|
workspace_label = f"{workspace_name} ({workspace_id})"
|
|
169
170
|
else:
|
|
170
171
|
workspace_label = str(workspace_id)
|
|
171
|
-
lines
|
|
172
|
+
lines: list[str] = []
|
|
173
|
+
if not hide_context_line:
|
|
174
|
+
lines.append(f"Context: profile={profile_name} workspace={workspace_label}")
|
|
172
175
|
if spec.cli_context_write and profile_name == "default":
|
|
173
176
|
lines.append("Warning: using default profile for a workspace-sensitive write command")
|
|
177
|
+
if not lines:
|
|
178
|
+
return
|
|
174
179
|
stream.write("\n".join(lines) + "\n")
|
|
175
180
|
|
|
176
181
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from Crypto.Cipher import PKCS1_v1_5
|
|
8
|
+
from Crypto.PublicKey import RSA
|
|
9
|
+
|
|
10
|
+
from ..backend_client import BackendClient
|
|
11
|
+
from ..config import get_default_base_url, get_timeout_seconds, normalize_base_url
|
|
12
|
+
from ..errors import QingflowApiError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class QingflowNativeLoginResult:
|
|
17
|
+
token: str
|
|
18
|
+
user_info: dict[str, Any]
|
|
19
|
+
login_token: str | None = None
|
|
20
|
+
flow: str = "qingflow_password"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class QingflowNativeLoginHelper:
|
|
24
|
+
def __init__(self, *, backend: BackendClient | None = None) -> None:
|
|
25
|
+
self._owns_backend = backend is None
|
|
26
|
+
self._backend = backend or BackendClient(timeout=get_timeout_seconds())
|
|
27
|
+
|
|
28
|
+
def close(self) -> None:
|
|
29
|
+
if self._owns_backend:
|
|
30
|
+
self._backend.close()
|
|
31
|
+
|
|
32
|
+
def login_with_password(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
base_url: str | None,
|
|
36
|
+
email: str,
|
|
37
|
+
password: str,
|
|
38
|
+
) -> QingflowNativeLoginResult:
|
|
39
|
+
normalized_base_url = normalize_base_url(base_url) or get_default_base_url()
|
|
40
|
+
normalized_email = str(email or "").strip()
|
|
41
|
+
normalized_password = str(password or "")
|
|
42
|
+
if not normalized_base_url:
|
|
43
|
+
raise QingflowApiError.config_error("base_url is required or configure default_base_url")
|
|
44
|
+
if not normalized_email:
|
|
45
|
+
raise QingflowApiError.config_error("email is required for Qingflow account login")
|
|
46
|
+
if not normalized_password:
|
|
47
|
+
raise QingflowApiError.config_error("password is required for Qingflow account login")
|
|
48
|
+
|
|
49
|
+
pubkey_payload = self._backend.public_request("GET", normalized_base_url, "/user/pubkey", qf_version=None)
|
|
50
|
+
pubkey = self._extract_pubkey(pubkey_payload)
|
|
51
|
+
encrypted_password = _encrypt_password(normalized_password, pubkey)
|
|
52
|
+
login_payload = self._backend.public_request(
|
|
53
|
+
"POST",
|
|
54
|
+
normalized_base_url,
|
|
55
|
+
"/user/login",
|
|
56
|
+
json_body={"email": normalized_email, "password": encrypted_password},
|
|
57
|
+
qf_version=None,
|
|
58
|
+
)
|
|
59
|
+
if not isinstance(login_payload, dict):
|
|
60
|
+
raise QingflowApiError(category="auth", message="Qingflow login did not return a valid response")
|
|
61
|
+
|
|
62
|
+
token = str(login_payload.get("token") or "").strip()
|
|
63
|
+
login_token = str(login_payload.get("loginToken") or "").strip() or None
|
|
64
|
+
if not token:
|
|
65
|
+
if login_token:
|
|
66
|
+
raise QingflowApiError(
|
|
67
|
+
category="auth",
|
|
68
|
+
message=(
|
|
69
|
+
"Qingflow account login requires additional security verification. "
|
|
70
|
+
"CLI password login currently does not complete the loginToken verification step."
|
|
71
|
+
),
|
|
72
|
+
details={"login_token_present": True},
|
|
73
|
+
)
|
|
74
|
+
raise QingflowApiError(
|
|
75
|
+
category="auth",
|
|
76
|
+
message="Qingflow login succeeded but did not return a token",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
user_info = login_payload.get("userInfo")
|
|
80
|
+
if not isinstance(user_info, dict):
|
|
81
|
+
user_info = {}
|
|
82
|
+
return QingflowNativeLoginResult(
|
|
83
|
+
token=token,
|
|
84
|
+
login_token=login_token,
|
|
85
|
+
user_info=user_info,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _extract_pubkey(self, payload: Any) -> str:
|
|
89
|
+
if not isinstance(payload, dict):
|
|
90
|
+
raise QingflowApiError(category="auth", message="Qingflow pubkey response is invalid")
|
|
91
|
+
pubkey = str(payload.get("pubkey") or "").strip()
|
|
92
|
+
if not pubkey:
|
|
93
|
+
raise QingflowApiError(category="auth", message="Qingflow pubkey response did not include pubkey")
|
|
94
|
+
return pubkey
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def login_with_qingflow_password(
|
|
98
|
+
*,
|
|
99
|
+
base_url: str | None,
|
|
100
|
+
email: str,
|
|
101
|
+
password: str,
|
|
102
|
+
) -> QingflowNativeLoginResult:
|
|
103
|
+
helper = QingflowNativeLoginHelper()
|
|
104
|
+
try:
|
|
105
|
+
return helper.login_with_password(base_url=base_url, email=email, password=password)
|
|
106
|
+
finally:
|
|
107
|
+
helper.close()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _encrypt_password(password: str, pubkey: str) -> str:
|
|
111
|
+
public_key = RSA.import_key(
|
|
112
|
+
"-----BEGIN PUBLIC KEY-----\n" + pubkey.strip() + "\n-----END PUBLIC KEY-----\n"
|
|
113
|
+
)
|
|
114
|
+
cipher = PKCS1_v1_5.new(public_key)
|
|
115
|
+
encrypted = cipher.encrypt(password.encode("utf-8"))
|
|
116
|
+
return base64.b64encode(encrypted).decode("ascii")
|
|
@@ -22,6 +22,7 @@ DEFAULT_REPOSITORY_AUTHOR_NAME = "qingflow-mcp"
|
|
|
22
22
|
DEFAULT_REPOSITORY_AUTHOR_EMAIL = "qingflow-mcp@local.invalid"
|
|
23
23
|
DEFAULT_REPOSITORY_INTERNAL_SHARE_TOKEN_KEY = "tokenKey"
|
|
24
24
|
DEFAULT_CREDIT_USAGE_RECORD_PATH = "/user/credit/usage"
|
|
25
|
+
DEFAULT_MCPORTER_CONFIG_PATH = "~/.openclaw/workspace/config/mcporter.json"
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
def get_mcp_home() -> Path:
|
|
@@ -33,6 +34,13 @@ def get_profiles_path() -> Path:
|
|
|
33
34
|
return get_mcp_home() / "profiles.json"
|
|
34
35
|
|
|
35
36
|
|
|
37
|
+
def get_mcporter_config_path() -> Path:
|
|
38
|
+
custom_path = os.getenv("QINGFLOW_MCP_MCPORTER_CONFIG_PATH") or os.getenv(
|
|
39
|
+
"QINGFLOW_MCP_AUTH_CONFIG_PATH"
|
|
40
|
+
)
|
|
41
|
+
return Path(custom_path).expanduser() if custom_path else Path(DEFAULT_MCPORTER_CONFIG_PATH)
|
|
42
|
+
|
|
43
|
+
|
|
36
44
|
def get_repository_metadata_dir() -> Path:
|
|
37
45
|
return get_mcp_home() / "repository-metadata"
|
|
38
46
|
|
|
@@ -43,14 +43,14 @@ class QingflowApiError(Exception):
|
|
|
43
43
|
def auth_required(cls, profile: str) -> "QingflowApiError":
|
|
44
44
|
return cls(
|
|
45
45
|
category="auth",
|
|
46
|
-
message=f"Profile '{profile}' is not logged in. Run auth_use_credential first.",
|
|
46
|
+
message=f"Profile '{profile}' is not logged in. Run auth login or auth_use_credential first.",
|
|
47
47
|
)
|
|
48
48
|
|
|
49
49
|
@classmethod
|
|
50
50
|
def workspace_not_selected(cls, profile: str) -> "QingflowApiError":
|
|
51
51
|
return cls(
|
|
52
52
|
category="workspace",
|
|
53
|
-
message=f"WORKSPACE_NOT_SELECTED: profile '{profile}' has no workspace from auth context. Re-run auth_use_credential.",
|
|
53
|
+
message=f"WORKSPACE_NOT_SELECTED: profile '{profile}' has no workspace from auth context. Re-run auth login or auth_use_credential.",
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
@classmethod
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .errors import QingflowApiError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
JS_MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def stringify_backend_id(value: Any) -> str | None:
|
|
12
|
+
"""Return an exact public id string for backend-originated identifiers."""
|
|
13
|
+
if value in (None, ""):
|
|
14
|
+
return None
|
|
15
|
+
if isinstance(value, bool):
|
|
16
|
+
return None
|
|
17
|
+
text = str(value).strip()
|
|
18
|
+
return text or None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_positive_id_text(value: Any, *, field_name: str) -> str:
|
|
22
|
+
"""Normalize a user-supplied id while rejecting JS-unsafe numeric input."""
|
|
23
|
+
if value in (None, "") or isinstance(value, bool):
|
|
24
|
+
raise QingflowApiError.config_error(f"{field_name} must be positive")
|
|
25
|
+
if isinstance(value, int):
|
|
26
|
+
if value <= 0:
|
|
27
|
+
raise QingflowApiError.config_error(f"{field_name} must be positive")
|
|
28
|
+
if value > JS_MAX_SAFE_INTEGER:
|
|
29
|
+
raise QingflowApiError.config_error(
|
|
30
|
+
f"{field_name} exceeds JavaScript's safe integer range; pass it as a string to avoid precision loss"
|
|
31
|
+
)
|
|
32
|
+
return str(value)
|
|
33
|
+
if isinstance(value, str):
|
|
34
|
+
text = value.strip()
|
|
35
|
+
if not text.isdecimal() or int(text) <= 0:
|
|
36
|
+
raise QingflowApiError.config_error(f"{field_name} must be a positive integer string")
|
|
37
|
+
return text
|
|
38
|
+
raise QingflowApiError.config_error(f"{field_name} must be a positive integer string")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize_positive_id_int(value: Any, *, field_name: str) -> int:
|
|
42
|
+
"""Normalize an id to Python int after the public boundary preserves it as text."""
|
|
43
|
+
return int(normalize_positive_id_text(value, field_name=field_name))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ids_equal(left: Any, right: Any) -> bool:
|
|
47
|
+
left_text = stringify_backend_id(left)
|
|
48
|
+
right_text = stringify_backend_id(right)
|
|
49
|
+
return left_text is not None and right_text is not None and left_text == right_text
|