@josephyan/qingflow-app-builder-mcp 0.2.0-beta.7 → 0.2.0-beta.71

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 (70) hide show
  1. package/README.md +5 -3
  2. package/docs/local-agent-install.md +21 -5
  3. package/npm/bin/qingflow-app-builder-mcp.mjs +1 -1
  4. package/npm/lib/runtime.mjs +168 -12
  5. package/package.json +1 -1
  6. package/pyproject.toml +4 -1
  7. package/skills/qingflow-app-builder/SKILL.md +155 -22
  8. package/skills/qingflow-app-builder/references/create-app.md +51 -21
  9. package/skills/qingflow-app-builder/references/environments.md +1 -1
  10. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
  11. package/skills/qingflow-app-builder/references/gotchas.md +28 -1
  12. package/skills/qingflow-app-builder/references/solution-playbooks.md +14 -12
  13. package/skills/qingflow-app-builder/references/tool-selection.md +47 -19
  14. package/skills/qingflow-app-builder/references/update-flow.md +112 -25
  15. package/skills/qingflow-app-builder/references/update-layout.md +11 -24
  16. package/skills/qingflow-app-builder/references/update-schema.md +1 -23
  17. package/skills/qingflow-app-builder/references/update-views.md +87 -21
  18. package/skills/qingflow-app-builder-code-integrations/SKILL.md +137 -0
  19. package/skills/qingflow-app-builder-code-integrations/agents/openai.yaml +4 -0
  20. package/skills/qingflow-app-builder-code-integrations/references/code-block.md +66 -0
  21. package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +77 -0
  22. package/src/qingflow_mcp/__init__.py +1 -1
  23. package/src/qingflow_mcp/backend_client.py +210 -0
  24. package/src/qingflow_mcp/builder_facade/models.py +1252 -3
  25. package/src/qingflow_mcp/builder_facade/service.py +11367 -2389
  26. package/src/qingflow_mcp/cli/__init__.py +1 -0
  27. package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
  28. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  29. package/src/qingflow_mcp/cli/commands/auth.py +78 -0
  30. package/src/qingflow_mcp/cli/commands/builder.py +515 -0
  31. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  32. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  33. package/src/qingflow_mcp/cli/commands/record.py +304 -0
  34. package/src/qingflow_mcp/cli/commands/task.py +89 -0
  35. package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
  36. package/src/qingflow_mcp/cli/context.py +48 -0
  37. package/src/qingflow_mcp/cli/formatters.py +355 -0
  38. package/src/qingflow_mcp/cli/json_io.py +50 -0
  39. package/src/qingflow_mcp/cli/main.py +149 -0
  40. package/src/qingflow_mcp/config.py +39 -0
  41. package/src/qingflow_mcp/import_store.py +121 -0
  42. package/src/qingflow_mcp/list_type_labels.py +24 -0
  43. package/src/qingflow_mcp/response_trim.py +668 -0
  44. package/src/qingflow_mcp/server.py +160 -18
  45. package/src/qingflow_mcp/server_app_builder.py +275 -68
  46. package/src/qingflow_mcp/server_app_user.py +219 -191
  47. package/src/qingflow_mcp/session_store.py +41 -1
  48. package/src/qingflow_mcp/solution/compiler/form_compiler.py +43 -4
  49. package/src/qingflow_mcp/solution/compiler/icon_utils.py +119 -45
  50. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +41 -2
  51. package/src/qingflow_mcp/solution/executor.py +107 -11
  52. package/src/qingflow_mcp/solution/spec_models.py +2 -0
  53. package/src/qingflow_mcp/tools/ai_builder_tools.py +2032 -127
  54. package/src/qingflow_mcp/tools/app_tools.py +419 -12
  55. package/src/qingflow_mcp/tools/approval_tools.py +571 -72
  56. package/src/qingflow_mcp/tools/auth_tools.py +398 -2
  57. package/src/qingflow_mcp/tools/code_block_tools.py +756 -0
  58. package/src/qingflow_mcp/tools/custom_button_tools.py +179 -0
  59. package/src/qingflow_mcp/tools/directory_tools.py +203 -31
  60. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  61. package/src/qingflow_mcp/tools/file_tools.py +1 -0
  62. package/src/qingflow_mcp/tools/import_tools.py +2150 -0
  63. package/src/qingflow_mcp/tools/package_tools.py +18 -4
  64. package/src/qingflow_mcp/tools/portal_tools.py +31 -0
  65. package/src/qingflow_mcp/tools/qingbi_report_tools.py +109 -7
  66. package/src/qingflow_mcp/tools/record_tools.py +9894 -1104
  67. package/src/qingflow_mcp/tools/solution_tools.py +115 -3
  68. package/src/qingflow_mcp/tools/task_context_tools.py +2040 -0
  69. package/src/qingflow_mcp/tools/task_tools.py +376 -225
  70. package/src/qingflow_mcp/tools/workspace_tools.py +163 -19
@@ -0,0 +1,355 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, TextIO
5
+
6
+
7
+ def emit_text_result(result: dict[str, Any], *, hint: str, stream: TextIO) -> None:
8
+ formatter = _FORMATTERS.get(hint, _format_generic)
9
+ text = formatter(result)
10
+ stream.write(text)
11
+ if not text.endswith("\n"):
12
+ stream.write("\n")
13
+
14
+
15
+ def _format_generic(result: dict[str, Any]) -> str:
16
+ lines: list[str] = []
17
+ title = _first_present(result, "status", "message")
18
+ if title:
19
+ lines.append(str(title))
20
+ data = result.get("data")
21
+ if isinstance(data, dict):
22
+ scalar_lines = _dict_scalar_lines(data)
23
+ if scalar_lines:
24
+ lines.extend(scalar_lines)
25
+ elif result:
26
+ scalar_lines = _dict_scalar_lines(result)
27
+ if scalar_lines:
28
+ lines.extend(scalar_lines)
29
+ if not lines:
30
+ lines.append(json.dumps(result, ensure_ascii=False, indent=2))
31
+ _append_warnings(lines, result.get("warnings"))
32
+ _append_verification(lines, result.get("verification"))
33
+ return "\n".join(lines) + "\n"
34
+
35
+
36
+ def _format_whoami(result: dict[str, Any]) -> str:
37
+ lines = [
38
+ f"User: {result.get('nick_name') or '-'} ({result.get('email') or '-'})",
39
+ f"UID: {result.get('uid')}",
40
+ f"Workspace: {result.get('selected_ws_name') or '-'} ({result.get('selected_ws_id') or '-'})",
41
+ f"QF Version: {result.get('qf_version') or '-'}",
42
+ ]
43
+ lines.append(f"Permission Level: {result.get('permission_level') or '-'}")
44
+ departments = result.get("departments") if isinstance(result.get("departments"), list) else []
45
+ roles = result.get("roles") if isinstance(result.get("roles"), list) else []
46
+ if departments:
47
+ lines.append(
48
+ "Departments: "
49
+ + ", ".join(
50
+ str(item.get("dept_name") or item.get("dept_id"))
51
+ for item in departments
52
+ if isinstance(item, dict)
53
+ )
54
+ )
55
+ if roles:
56
+ lines.append(
57
+ "Roles: "
58
+ + ", ".join(
59
+ str(item.get("role_name") or item.get("role_id"))
60
+ for item in roles
61
+ if isinstance(item, dict)
62
+ )
63
+ )
64
+ _append_warnings(lines, result.get("warnings"))
65
+ return "\n".join(lines) + "\n"
66
+
67
+
68
+ def _format_workspace_list(result: dict[str, Any]) -> str:
69
+ page = result.get("page") if isinstance(result.get("page"), dict) else {}
70
+ items = page.get("list") if isinstance(page.get("list"), list) else []
71
+ rows = []
72
+ for item in items:
73
+ if not isinstance(item, dict):
74
+ continue
75
+ rows.append(
76
+ [
77
+ str(item.get("wsId") or ""),
78
+ str(item.get("workspaceName") or item.get("wsName") or ""),
79
+ str(item.get("remark") or ""),
80
+ ]
81
+ )
82
+ return _render_titled_table("Workspaces", ["ws_id", "name", "remark"], rows)
83
+
84
+
85
+ def _format_app_items(result: dict[str, Any]) -> str:
86
+ items = result.get("items")
87
+ if not isinstance(items, list):
88
+ items = result.get("apps")
89
+ rows = []
90
+ for item in items or []:
91
+ if not isinstance(item, dict):
92
+ continue
93
+ rows.append(
94
+ [
95
+ str(item.get("app_key") or item.get("appKey") or ""),
96
+ str(item.get("app_name") or item.get("name") or item.get("title") or ""),
97
+ str(item.get("package_name") or item.get("tag_name") or ""),
98
+ ]
99
+ )
100
+ return _render_titled_table("Apps", ["app_key", "app_name", "package"], rows)
101
+
102
+
103
+ def _format_app_get(result: dict[str, Any]) -> str:
104
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
105
+ lines = [
106
+ f"App: {data.get('app_name') or '-'}",
107
+ f"App Key: {data.get('app_key') or '-'}",
108
+ f"Can Create: {data.get('can_create')}",
109
+ ]
110
+ import_capability = data.get("import_capability")
111
+ if isinstance(import_capability, dict):
112
+ lines.append(
113
+ "Import Capability: "
114
+ f"{import_capability.get('auth_source') or 'unknown'} / "
115
+ f"can_import={import_capability.get('can_import')}"
116
+ )
117
+ editability = data.get("editability") if isinstance(data.get("editability"), dict) else {}
118
+ if editability:
119
+ lines.append(
120
+ "Editability: "
121
+ f"form={editability.get('can_edit_form')} / "
122
+ f"flow={editability.get('can_edit_flow')} / "
123
+ f"views={editability.get('can_edit_views')} / "
124
+ f"charts={editability.get('can_edit_charts')}"
125
+ )
126
+ views = data.get("accessible_views") if isinstance(data.get("accessible_views"), list) else []
127
+ lines.append(f"Accessible Views: {len(views)}")
128
+ for item in views[:10]:
129
+ if isinstance(item, dict):
130
+ lines.append(f"- {item.get('view_id')}: {item.get('name')}")
131
+ _append_warnings(lines, result.get("warnings"))
132
+ return "\n".join(lines) + "\n"
133
+
134
+
135
+ def _format_workspace_select(result: dict[str, Any]) -> str:
136
+ lines = [
137
+ f"Workspace: {result.get('selected_ws_name') or '-'} ({result.get('selected_ws_id') or '-'})",
138
+ f"QF Version: {result.get('qf_version') or '-'}",
139
+ ]
140
+ workspace_version = result.get("workspace_version") if isinstance(result.get("workspace_version"), dict) else {}
141
+ if workspace_version:
142
+ lines.append(
143
+ "Workspace Version: "
144
+ f"{workspace_version.get('display_name') or '-'} "
145
+ f"({workspace_version.get('level_name') or workspace_version.get('level_code') or '-'})"
146
+ )
147
+ if workspace_version.get("being_trial") is not None:
148
+ lines.append(f"Trial: {workspace_version.get('being_trial')}")
149
+ if workspace_version.get("expire_date") is not None:
150
+ lines.append(f"Expire Date: {workspace_version.get('expire_date')}")
151
+ return "\n".join(lines) + "\n"
152
+
153
+
154
+ def _format_record_list(result: dict[str, Any]) -> str:
155
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
156
+ items = data.get("items") if isinstance(data.get("items"), list) else []
157
+ lines = [f"Returned Records: {len(items)}"]
158
+ for item in items[:10]:
159
+ if isinstance(item, dict):
160
+ lines.append(json.dumps(item, ensure_ascii=False))
161
+ if len(items) > 10:
162
+ lines.append(f"... {len(items) - 10} more")
163
+ _append_warnings(lines, result.get("warnings"))
164
+ _append_verification(lines, result.get("verification"))
165
+ return "\n".join(lines) + "\n"
166
+
167
+
168
+ def _format_task_list(result: dict[str, Any]) -> str:
169
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
170
+ items = data.get("items") if isinstance(data.get("items"), list) else []
171
+ rows = []
172
+ for item in items:
173
+ if not isinstance(item, dict):
174
+ continue
175
+ rows.append(
176
+ [
177
+ str(item.get("app_key") or ""),
178
+ str(item.get("record_id") or ""),
179
+ str(item.get("workflow_node_id") or ""),
180
+ str(item.get("title") or item.get("task_name") or ""),
181
+ ]
182
+ )
183
+ output = _render_titled_table("Tasks", ["app_key", "record_id", "node_id", "title"], rows)
184
+ lines = output.rstrip("\n").split("\n")
185
+ _append_warnings(lines, result.get("warnings"))
186
+ return "\n".join(lines) + "\n"
187
+
188
+
189
+ def _format_task_get(result: dict[str, Any]) -> str:
190
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
191
+ task = data.get("task") if isinstance(data.get("task"), dict) else {}
192
+ record = data.get("record") if isinstance(data.get("record"), dict) else {}
193
+ capabilities = data.get("capabilities") if isinstance(data.get("capabilities"), dict) else {}
194
+ update_schema = data.get("update_schema") if isinstance(data.get("update_schema"), dict) else {}
195
+ writable_fields = update_schema.get("writable_fields") if isinstance(update_schema.get("writable_fields"), list) else []
196
+ lines = [
197
+ f"Task: {task.get('app_key') or '-'} / {task.get('record_id') or '-'} / {task.get('workflow_node_id') or '-'}",
198
+ f"Node: {task.get('workflow_node_name') or '-'}",
199
+ f"Apply Status: {record.get('apply_status')}",
200
+ f"Available Actions: {', '.join(str(item) for item in (capabilities.get('available_actions') or [])) or '-'}",
201
+ f"Editable Fields: {len(writable_fields)}",
202
+ ]
203
+ if writable_fields:
204
+ for item in writable_fields[:10]:
205
+ if isinstance(item, dict):
206
+ lines.append(f"- {item.get('title') or '-'} ({item.get('kind') or 'field'})")
207
+ blockers = update_schema.get("blockers") if isinstance(update_schema.get("blockers"), list) else []
208
+ if blockers:
209
+ lines.append("Update Schema Blockers:")
210
+ for item in blockers:
211
+ lines.append(f"- {item}")
212
+ schema_warnings = update_schema.get("warnings") if isinstance(update_schema.get("warnings"), list) else []
213
+ if schema_warnings:
214
+ lines.append("Update Schema Warnings:")
215
+ for item in schema_warnings:
216
+ if isinstance(item, dict):
217
+ lines.append(f"- {item.get('code') or 'WARNING'}: {item.get('message') or ''}".rstrip())
218
+ else:
219
+ lines.append(f"- {item}")
220
+ _append_warnings(lines, result.get("warnings"))
221
+ return "\n".join(lines) + "\n"
222
+
223
+
224
+ def _format_import_verify(result: dict[str, Any]) -> str:
225
+ lines = [
226
+ f"App Key: {result.get('app_key') or '-'}",
227
+ f"File: {result.get('file_name') or result.get('file_path') or '-'}",
228
+ f"Can Import: {result.get('can_import')}",
229
+ f"Apply Rows: {result.get('apply_rows')}",
230
+ f"Verification ID: {result.get('verification_id') or '-'}",
231
+ ]
232
+ issues = result.get("issues") if isinstance(result.get("issues"), list) else []
233
+ if issues:
234
+ lines.append("Issues:")
235
+ for issue in issues:
236
+ if isinstance(issue, dict):
237
+ lines.append(f"- {issue.get('code') or 'ISSUE'}: {issue.get('message') or issue}")
238
+ else:
239
+ lines.append(f"- {issue}")
240
+ _append_warnings(lines, result.get("warnings"))
241
+ _append_verification(lines, result.get("verification"))
242
+ return "\n".join(lines) + "\n"
243
+
244
+
245
+ def _format_import_status(result: dict[str, Any]) -> str:
246
+ lines = [
247
+ f"Status: {result.get('status') or '-'}",
248
+ f"Import ID: {result.get('import_id') or '-'}",
249
+ f"Process ID: {result.get('process_id_str') or '-'}",
250
+ f"Success Rows: {result.get('success_rows') or 0}",
251
+ f"Failed Rows: {result.get('failed_rows') or 0}",
252
+ f"Progress: {result.get('progress') or '-'}",
253
+ ]
254
+ _append_warnings(lines, result.get("warnings"))
255
+ _append_verification(lines, result.get("verification"))
256
+ return "\n".join(lines) + "\n"
257
+
258
+
259
+ def _format_builder_summary(result: dict[str, Any]) -> str:
260
+ lines = []
261
+ if "status" in result:
262
+ lines.append(f"Status: {result.get('status')}")
263
+ if "app_key" in result:
264
+ lines.append(f"App Key: {result.get('app_key')}")
265
+ if "dash_key" in result:
266
+ lines.append(f"Dash Key: {result.get('dash_key')}")
267
+ if "verified" in result:
268
+ lines.append(f"Verified: {result.get('verified')}")
269
+ data = result.get("data")
270
+ if isinstance(data, dict):
271
+ scalar_lines = _dict_scalar_lines(data)
272
+ lines.extend(scalar_lines[:8])
273
+ _append_warnings(lines, result.get("warnings"))
274
+ _append_verification(lines, result.get("verification"))
275
+ if not lines:
276
+ return _format_generic(result)
277
+ return "\n".join(lines) + "\n"
278
+
279
+
280
+ def emit_json_result(result: dict[str, Any], *, stream: TextIO) -> None:
281
+ json.dump(result, stream, ensure_ascii=False, indent=2)
282
+ stream.write("\n")
283
+
284
+
285
+ def _render_titled_table(title: str, headers: list[str], rows: list[list[str]]) -> str:
286
+ lines = [title]
287
+ if not rows:
288
+ lines.append("(empty)")
289
+ return "\n".join(lines) + "\n"
290
+ widths = [len(header) for header in headers]
291
+ for row in rows:
292
+ for index, cell in enumerate(row):
293
+ widths[index] = max(widths[index], len(cell))
294
+ header_line = " ".join(header.ljust(widths[index]) for index, header in enumerate(headers))
295
+ lines.append(header_line)
296
+ lines.append(" ".join("-" * width for width in widths))
297
+ for row in rows:
298
+ lines.append(" ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)))
299
+ return "\n".join(lines) + "\n"
300
+
301
+
302
+ def _dict_scalar_lines(payload: dict[str, Any]) -> list[str]:
303
+ lines: list[str] = []
304
+ for key, value in payload.items():
305
+ if isinstance(value, (str, int, float, bool)) or value is None:
306
+ lines.append(f"{key}: {value}")
307
+ return lines
308
+
309
+
310
+ def _append_warnings(lines: list[str], warnings: Any) -> None:
311
+ if not isinstance(warnings, list) or not warnings:
312
+ return
313
+ lines.append("Warnings:")
314
+ for warning in warnings:
315
+ if isinstance(warning, dict):
316
+ code = warning.get("code")
317
+ message = warning.get("message")
318
+ if code or message:
319
+ lines.append(f"- {code or 'WARNING'}: {message or ''}".rstrip())
320
+ else:
321
+ lines.append(f"- {json.dumps(warning, ensure_ascii=False)}")
322
+ else:
323
+ lines.append(f"- {warning}")
324
+
325
+
326
+ def _append_verification(lines: list[str], verification: Any) -> None:
327
+ if not isinstance(verification, dict) or not verification:
328
+ return
329
+ lines.append("Verification:")
330
+ for key, value in verification.items():
331
+ if isinstance(value, (str, int, float, bool)) or value is None:
332
+ lines.append(f"- {key}: {value}")
333
+
334
+
335
+ def _first_present(payload: dict[str, Any], *keys: str) -> Any:
336
+ for key in keys:
337
+ if key in payload and payload.get(key) is not None:
338
+ return payload.get(key)
339
+ return None
340
+
341
+
342
+ _FORMATTERS = {
343
+ "auth_whoami": _format_whoami,
344
+ "workspace_list": _format_workspace_list,
345
+ "workspace_select": _format_workspace_select,
346
+ "app_list": _format_app_items,
347
+ "app_search": _format_app_items,
348
+ "app_get": _format_app_get,
349
+ "record_list": _format_record_list,
350
+ "task_list": _format_task_list,
351
+ "task_get": _format_task_get,
352
+ "import_verify": _format_import_verify,
353
+ "import_status": _format_import_status,
354
+ "builder_summary": _format_builder_summary,
355
+ }
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ..errors import QingflowApiError
9
+
10
+
11
+ def load_json_value(path: str, *, option_name: str) -> Any:
12
+ if not path:
13
+ raise QingflowApiError.config_error(f"{option_name} is required")
14
+ if path == "-":
15
+ raw = sys.stdin.read()
16
+ else:
17
+ try:
18
+ raw = Path(path).expanduser().read_text(encoding="utf-8")
19
+ except OSError as exc:
20
+ raise QingflowApiError.config_error(f"failed to read {option_name} from '{path}': {exc}") from exc
21
+ try:
22
+ return json.loads(raw)
23
+ except json.JSONDecodeError as exc:
24
+ raise QingflowApiError.config_error(f"{option_name} must contain valid JSON: {exc}") from exc
25
+
26
+
27
+ def load_json_object(path: str, *, option_name: str) -> dict[str, Any]:
28
+ payload = load_json_value(path, option_name=option_name)
29
+ if not isinstance(payload, dict):
30
+ raise QingflowApiError.config_error(f"{option_name} must be a JSON object")
31
+ return payload
32
+
33
+
34
+ def load_json_list(path: str, *, option_name: str) -> list[Any]:
35
+ payload = load_json_value(path, option_name=option_name)
36
+ if not isinstance(payload, list):
37
+ raise QingflowApiError.config_error(f"{option_name} must be a JSON array")
38
+ return payload
39
+
40
+
41
+ def load_optional_json_object(path: str | None, *, option_name: str) -> dict[str, Any] | None:
42
+ if not path:
43
+ return None
44
+ return load_json_object(path, option_name=option_name)
45
+
46
+
47
+ def load_optional_json_list(path: str | None, *, option_name: str) -> list[Any]:
48
+ if not path:
49
+ return []
50
+ return load_json_list(path, option_name=option_name)
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from typing import Any, Callable, TextIO
7
+
8
+ from ..errors import QingflowApiError
9
+ from ..response_trim import resolve_cli_tool_name, trim_error_response, trim_public_response
10
+ from .context import CliContext, build_cli_context
11
+ from .formatters import emit_json_result, emit_text_result
12
+ from .commands import register_all_commands
13
+
14
+
15
+ Handler = Callable[[argparse.Namespace, CliContext], dict[str, Any]]
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(prog="qingflow", description="Qingflow CLI")
20
+ parser.add_argument("--profile", default="default", help="会话 profile,默认 default")
21
+ parser.add_argument("--json", action="store_true", help="输出 JSON")
22
+ subparsers = parser.add_subparsers(dest="command", required=True)
23
+ register_all_commands(subparsers)
24
+ return parser
25
+
26
+
27
+ def main(argv: list[str] | None = None) -> None:
28
+ raise SystemExit(run(argv))
29
+
30
+
31
+ def run(
32
+ argv: list[str] | None = None,
33
+ *,
34
+ context_factory: Callable[[], CliContext] = build_cli_context,
35
+ stdout: TextIO | None = None,
36
+ stderr: TextIO | None = None,
37
+ ) -> int:
38
+ out = stdout or sys.stdout
39
+ err = stderr or sys.stderr
40
+ parser = build_parser()
41
+ normalized_argv = _normalize_global_args(list(argv) if argv is not None else sys.argv[1:])
42
+ try:
43
+ args = parser.parse_args(normalized_argv)
44
+ except SystemExit as exc:
45
+ return int(exc.code or 0)
46
+ handler = getattr(args, "handler", None)
47
+ if handler is None:
48
+ parser.print_help(out)
49
+ return 2
50
+ context = context_factory()
51
+ try:
52
+ result = handler(args, context)
53
+ except RuntimeError as exc:
54
+ payload = trim_error_response(_parse_error_payload(exc))
55
+ return _emit_error(payload, json_mode=bool(args.json), stdout=out, stderr=err)
56
+ except QingflowApiError as exc:
57
+ payload = trim_error_response(exc.to_dict())
58
+ return _emit_error(payload, json_mode=bool(args.json), stdout=out, stderr=err)
59
+ finally:
60
+ context.close()
61
+
62
+ exit_code = _result_exit_code(result)
63
+ trimmed_result = trim_public_response(resolve_cli_tool_name(args), result) if isinstance(result, dict) else result
64
+ stream = out if bool(args.json) or exit_code == 0 else err
65
+ if bool(args.json):
66
+ emit_json_result(trimmed_result, stream=stream)
67
+ else:
68
+ emit_text_result(trimmed_result, hint=getattr(args, "format_hint", ""), stream=stream)
69
+ return exit_code
70
+
71
+
72
+ def _normalize_global_args(argv: list[str]) -> list[str]:
73
+ global_args: list[str] = []
74
+ remaining: list[str] = []
75
+ index = 0
76
+ while index < len(argv):
77
+ token = argv[index]
78
+ if token == "--json":
79
+ global_args.append(token)
80
+ index += 1
81
+ continue
82
+ if token == "--profile":
83
+ global_args.append(token)
84
+ if index + 1 >= len(argv):
85
+ global_args.append("")
86
+ break
87
+ global_args.append(argv[index + 1])
88
+ index += 2
89
+ continue
90
+ if token.startswith("--profile="):
91
+ global_args.append(token)
92
+ index += 1
93
+ continue
94
+ remaining.append(token)
95
+ index += 1
96
+ return global_args + remaining
97
+
98
+
99
+ def _parse_error_payload(exc: RuntimeError) -> dict[str, Any]:
100
+ raw = str(exc)
101
+ try:
102
+ payload = json.loads(raw)
103
+ except json.JSONDecodeError:
104
+ return {"category": "runtime", "message": raw}
105
+ return payload if isinstance(payload, dict) else {"category": "runtime", "message": raw}
106
+
107
+
108
+ def _emit_error(payload: dict[str, Any], *, json_mode: bool, stdout: TextIO, stderr: TextIO) -> int:
109
+ exit_code = _error_exit_code(payload)
110
+ if json_mode:
111
+ emit_json_result(payload, stream=stdout)
112
+ return exit_code
113
+ lines = [
114
+ f"Category: {payload.get('category') or 'error'}",
115
+ f"Message: {payload.get('message') or 'Unknown error'}",
116
+ ]
117
+ if payload.get("backend_code") is not None:
118
+ lines.append(f"Backend Code: {payload.get('backend_code')}")
119
+ if payload.get("request_id"):
120
+ lines.append(f"Request ID: {payload.get('request_id')}")
121
+ details = payload.get("details")
122
+ if isinstance(details, dict):
123
+ for key, value in details.items():
124
+ if isinstance(value, (str, int, float, bool)) or value is None:
125
+ lines.append(f"{key}: {value}")
126
+ stderr.write("\n".join(lines) + "\n")
127
+ return exit_code
128
+
129
+
130
+ def _error_exit_code(payload: dict[str, Any]) -> int:
131
+ category = str(payload.get("category") or "").lower()
132
+ if category in {"auth", "workspace"}:
133
+ return 3
134
+ return 4
135
+
136
+
137
+ def _result_exit_code(result: dict[str, Any]) -> int:
138
+ if not isinstance(result, dict):
139
+ return 0
140
+ if result.get("ok") is False:
141
+ return 4
142
+ status = str(result.get("status") or "").lower()
143
+ if status in {"failed", "blocked"}:
144
+ return 4
145
+ return 0
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main()
@@ -12,6 +12,8 @@ DEFAULT_USER_AGENT = "qingflow-mcp/1.0"
12
12
  DEFAULT_RECORD_LIST_TYPE = 8
13
13
  ATTACHMENT_QUESTION_TYPE = 13
14
14
  DEFAULT_BASE_URL = "https://qingflow.com/api"
15
+ DEFAULT_FEEDBACK_APP_KEY = "e0d017kju002"
16
+ DEFAULT_FEEDBACK_QSOURCE_TOKEN = "mcp-feedback-7755d14748fc"
15
17
 
16
18
 
17
19
  def get_mcp_home() -> Path:
@@ -136,6 +138,43 @@ def get_default_qf_version() -> str | None:
136
138
  return normalized or None
137
139
 
138
140
 
141
+ def get_feedback_qsource_token() -> str | None:
142
+ """获取反馈 q-source 被动入口 token"""
143
+ value = get_config_value(
144
+ "feedback.qsource_token",
145
+ env_var="QINGFLOW_MCP_FEEDBACK_QSOURCE_TOKEN",
146
+ default=DEFAULT_FEEDBACK_QSOURCE_TOKEN,
147
+ )
148
+ if value is None:
149
+ return None
150
+ normalized = str(value).strip()
151
+ return normalized or None
152
+
153
+
154
+ def get_feedback_base_url() -> str | None:
155
+ """获取反馈 q-source 使用的 base URL"""
156
+ value = get_config_value(
157
+ "feedback.base_url",
158
+ env_var="QINGFLOW_MCP_FEEDBACK_BASE_URL",
159
+ default=None,
160
+ )
161
+ if value is None:
162
+ return get_default_base_url()
163
+ normalized = normalize_base_url(value)
164
+ return normalized or get_default_base_url()
165
+
166
+
167
+ def get_feedback_app_key() -> str:
168
+ """获取内部反馈表 app_key"""
169
+ value = get_config_value(
170
+ "feedback.app_key",
171
+ env_var="QINGFLOW_MCP_FEEDBACK_APP_KEY",
172
+ default=DEFAULT_FEEDBACK_APP_KEY,
173
+ )
174
+ normalized = str(value or "").strip()
175
+ return normalized or DEFAULT_FEEDBACK_APP_KEY
176
+
177
+
139
178
  def get_timeout_seconds() -> float:
140
179
  """获取 HTTP 超时秒数"""
141
180
  value = get_config_value(