@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.
Files changed (45) hide show
  1. package/README.md +2 -2
  2. package/docs/local-agent-install.md +9 -3
  3. package/npm/lib/runtime.mjs +10 -3
  4. package/package.json +1 -1
  5. package/pyproject.toml +1 -1
  6. package/skills/qingflow-app-user/SKILL.md +21 -12
  7. package/skills/qingflow-app-user/references/data-gotchas.md +1 -1
  8. package/skills/qingflow-app-user/references/public-surface-sync.md +70 -0
  9. package/skills/qingflow-app-user/references/record-patterns.md +1 -1
  10. package/skills/qingflow-record-analysis/SKILL.md +44 -2
  11. package/skills/qingflow-record-insert/SKILL.md +3 -0
  12. package/skills/qingflow-record-update/SKILL.md +3 -0
  13. package/skills/qingflow-task-ops/SKILL.md +31 -10
  14. package/src/qingflow_mcp/__init__.py +33 -1
  15. package/src/qingflow_mcp/builder_facade/models.py +14 -4
  16. package/src/qingflow_mcp/builder_facade/service.py +1582 -124
  17. package/src/qingflow_mcp/cli/commands/auth.py +69 -1
  18. package/src/qingflow_mcp/cli/commands/builder.py +4 -3
  19. package/src/qingflow_mcp/cli/commands/record.py +5 -5
  20. package/src/qingflow_mcp/cli/commands/task.py +74 -22
  21. package/src/qingflow_mcp/cli/commands/workspace.py +22 -0
  22. package/src/qingflow_mcp/cli/formatters.py +287 -48
  23. package/src/qingflow_mcp/cli/main.py +6 -1
  24. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  25. package/src/qingflow_mcp/config.py +8 -0
  26. package/src/qingflow_mcp/errors.py +2 -2
  27. package/src/qingflow_mcp/id_utils.py +49 -0
  28. package/src/qingflow_mcp/public_surface.py +11 -1
  29. package/src/qingflow_mcp/response_trim.py +380 -9
  30. package/src/qingflow_mcp/server.py +4 -0
  31. package/src/qingflow_mcp/server_app_builder.py +11 -1
  32. package/src/qingflow_mcp/server_app_user.py +24 -0
  33. package/src/qingflow_mcp/session_store.py +69 -15
  34. package/src/qingflow_mcp/solution/compiler/form_compiler.py +2 -2
  35. package/src/qingflow_mcp/solution/executor.py +2 -2
  36. package/src/qingflow_mcp/tools/ai_builder_tools.py +48 -18
  37. package/src/qingflow_mcp/tools/app_tools.py +1 -0
  38. package/src/qingflow_mcp/tools/auth_tools.py +271 -12
  39. package/src/qingflow_mcp/tools/base.py +6 -2
  40. package/src/qingflow_mcp/tools/code_block_tools.py +2 -2
  41. package/src/qingflow_mcp/tools/import_tools.py +36 -2
  42. package/src/qingflow_mcp/tools/record_tools.py +410 -156
  43. package/src/qingflow_mcp/tools/resource_read_tools.py +114 -32
  44. package/src/qingflow_mcp/tools/task_context_tools.py +899 -141
  45. 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
- rows = []
176
+ lines = ["Tasks"]
152
177
  for item in items:
153
178
  if not isinstance(item, dict):
154
179
  continue
155
- rows.append(
156
- [
157
- str(item.get("app_key") or ""),
158
- str(item.get("record_id") or ""),
159
- str(item.get("workflow_node_id") or ""),
160
- str(item.get("title") or item.get("task_name") or ""),
161
- ]
162
- )
163
- output = _render_titled_table("Tasks", ["app_key", "record_id", "node_id", "title"], rows)
164
- lines = output.rstrip("\n").split("\n")
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
- record = data.get("record") if isinstance(data.get("record"), dict) else {}
173
- capabilities = data.get("capabilities") if isinstance(data.get("capabilities"), dict) else {}
174
- update_schema = data.get("update_schema") if isinstance(data.get("update_schema"), dict) else {}
175
- writable_fields = update_schema.get("writable_fields") if isinstance(update_schema.get("writable_fields"), list) else []
176
- lines = [
177
- f"Task: {task.get('app_key') or '-'} / {task.get('record_id') or '-'} / {task.get('workflow_node_id') or '-'}",
178
- f"Node: {task.get('workflow_node_name') or '-'}",
179
- f"Apply Status: {record.get('apply_status')}",
180
- f"Available Actions: {', '.join(str(item) for item in (capabilities.get('available_actions') or [])) or '-'}",
181
- f"Editable Fields: {len(writable_fields)}",
182
- ]
183
- if writable_fields:
184
- for item in writable_fields[:10]:
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
- blockers = update_schema.get("blockers") if isinstance(update_schema.get("blockers"), list) else []
188
- if blockers:
189
- lines.append("Update Schema Blockers:")
190
- for item in blockers:
191
- lines.append(f"- {item}")
192
- schema_warnings = update_schema.get("warnings") if isinstance(update_schema.get("warnings"), list) else []
193
- if schema_warnings:
194
- lines.append("Update Schema Warnings:")
195
- for item in schema_warnings:
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
- lines.append(f"- {item.get('code') or 'WARNING'}: {item.get('message') or ''}".rstrip())
198
- else:
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
- issues = result.get("issues") if isinstance(result.get("issues"), list) else []
213
- if issues:
214
- lines.append("Issues:")
215
- for issue in issues:
216
- if isinstance(issue, dict):
217
- lines.append(f"- {issue.get('code') or 'ISSUE'}: {issue.get('message') or issue}")
218
- else:
219
- lines.append(f"- {issue}")
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"Success Rows: {result.get('success_rows') or 0}",
231
- f"Failed Rows: {result.get('failed_rows') or 0}",
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 = [f"Context: profile={profile_name} workspace={workspace_label}"]
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