@josephyan/qingflow-cli 0.2.0-beta.1000

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 (92) hide show
  1. package/README.md +31 -0
  2. package/docs/local-agent-install.md +309 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +346 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +37 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +649 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +1846 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +16502 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +112 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +539 -0
  21. package/src/qingflow_mcp/cli/commands/chart.py +18 -0
  22. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  23. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  24. package/src/qingflow_mcp/cli/commands/portal.py +25 -0
  25. package/src/qingflow_mcp/cli/commands/record.py +331 -0
  26. package/src/qingflow_mcp/cli/commands/repo.py +80 -0
  27. package/src/qingflow_mcp/cli/commands/task.py +141 -0
  28. package/src/qingflow_mcp/cli/commands/view.py +18 -0
  29. package/src/qingflow_mcp/cli/commands/workspace.py +110 -0
  30. package/src/qingflow_mcp/cli/context.py +60 -0
  31. package/src/qingflow_mcp/cli/formatters.py +573 -0
  32. package/src/qingflow_mcp/cli/json_io.py +50 -0
  33. package/src/qingflow_mcp/cli/main.py +186 -0
  34. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  35. package/src/qingflow_mcp/cli/terminal_ui.py +173 -0
  36. package/src/qingflow_mcp/config.py +407 -0
  37. package/src/qingflow_mcp/errors.py +66 -0
  38. package/src/qingflow_mcp/id_utils.py +49 -0
  39. package/src/qingflow_mcp/import_store.py +121 -0
  40. package/src/qingflow_mcp/json_types.py +18 -0
  41. package/src/qingflow_mcp/list_type_labels.py +76 -0
  42. package/src/qingflow_mcp/public_surface.py +243 -0
  43. package/src/qingflow_mcp/repository_store.py +71 -0
  44. package/src/qingflow_mcp/response_trim.py +841 -0
  45. package/src/qingflow_mcp/server.py +216 -0
  46. package/src/qingflow_mcp/server_app_builder.py +543 -0
  47. package/src/qingflow_mcp/server_app_user.py +386 -0
  48. package/src/qingflow_mcp/session_store.py +369 -0
  49. package/src/qingflow_mcp/solution/__init__.py +6 -0
  50. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  51. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  52. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  53. package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
  54. package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
  55. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  56. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  57. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  58. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  59. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  60. package/src/qingflow_mcp/solution/design_session.py +222 -0
  61. package/src/qingflow_mcp/solution/design_store.py +100 -0
  62. package/src/qingflow_mcp/solution/executor.py +2398 -0
  63. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  64. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  65. package/src/qingflow_mcp/solution/run_store.py +244 -0
  66. package/src/qingflow_mcp/solution/spec_models.py +855 -0
  67. package/src/qingflow_mcp/tools/__init__.py +1 -0
  68. package/src/qingflow_mcp/tools/ai_builder_tools.py +3449 -0
  69. package/src/qingflow_mcp/tools/app_tools.py +926 -0
  70. package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
  71. package/src/qingflow_mcp/tools/auth_tools.py +1133 -0
  72. package/src/qingflow_mcp/tools/base.py +281 -0
  73. package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
  74. package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
  75. package/src/qingflow_mcp/tools/directory_tools.py +675 -0
  76. package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
  77. package/src/qingflow_mcp/tools/file_tools.py +409 -0
  78. package/src/qingflow_mcp/tools/import_tools.py +2223 -0
  79. package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
  80. package/src/qingflow_mcp/tools/package_tools.py +326 -0
  81. package/src/qingflow_mcp/tools/portal_tools.py +158 -0
  82. package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
  83. package/src/qingflow_mcp/tools/record_tools.py +14291 -0
  84. package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
  85. package/src/qingflow_mcp/tools/resource_read_tools.py +503 -0
  86. package/src/qingflow_mcp/tools/role_tools.py +112 -0
  87. package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
  88. package/src/qingflow_mcp/tools/task_context_tools.py +2986 -0
  89. package/src/qingflow_mcp/tools/task_tools.py +889 -0
  90. package/src/qingflow_mcp/tools/view_tools.py +335 -0
  91. package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
  92. package/src/qingflow_mcp/tools/workspace_tools.py +266 -0
@@ -0,0 +1,407 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+ from urllib.parse import urlsplit, urlunsplit
8
+
9
+ DEFAULT_PROFILE = "default"
10
+ DEFAULT_TIMEOUT_SECONDS = 30.0
11
+ DEFAULT_USER_AGENT = "qingflow-mcp/1.0"
12
+ DEFAULT_RECORD_LIST_TYPE = 8
13
+ ATTACHMENT_QUESTION_TYPE = 13
14
+ DEFAULT_BASE_URL = "https://qingflow.com/api"
15
+ DEFAULT_FEEDBACK_APP_KEY = "e0d017kju002"
16
+ DEFAULT_FEEDBACK_QSOURCE_TOKEN = "mcp-feedback-7755d14748fc"
17
+ DEFAULT_REPOSITORY_GIT_REMOTE_TEMPLATE = "git@hackers.oalite.com:{group}/{repo}.git"
18
+ DEFAULT_REPOSITORY_PREVIEW_ADDRESS_TEMPLATE = "https://%s.preview.oalite.com"
19
+ DEFAULT_REPOSITORY_DEVELOP_BRANCH = "develop"
20
+ DEFAULT_REPOSITORY_PROD_BRANCH = "prod"
21
+ DEFAULT_REPOSITORY_AUTHOR_NAME = "qingflow-mcp"
22
+ DEFAULT_REPOSITORY_AUTHOR_EMAIL = "qingflow-mcp@local.invalid"
23
+ DEFAULT_REPOSITORY_INTERNAL_SHARE_TOKEN_KEY = "tokenKey"
24
+ DEFAULT_CREDIT_USAGE_RECORD_PATH = "/user/credit/usage"
25
+ DEFAULT_MCPORTER_CONFIG_PATH = "~/.openclaw/workspace/config/mcporter.json"
26
+
27
+
28
+ def get_mcp_home() -> Path:
29
+ custom_home = os.getenv("QINGFLOW_MCP_HOME")
30
+ return Path(custom_home).expanduser() if custom_home else Path.home() / ".qingflow-mcp"
31
+
32
+
33
+ def get_profiles_path() -> Path:
34
+ return get_mcp_home() / "profiles.json"
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
+
44
+ def get_repository_metadata_dir() -> Path:
45
+ return get_mcp_home() / "repository-metadata"
46
+
47
+
48
+ def get_config_file_paths() -> list[Path]:
49
+ """
50
+ 获取可能的配置文件路径列表,按优先级排序:
51
+ 1. 环境变量 QINGFLOW_MCP_CONFIG_PATH 指定的路径
52
+ 2. 当前工作目录下的 qingflow-mcp.config.json
53
+ 3. MCP home 目录下的 config.json
54
+ 4. 系统级配置 (Linux/Mac: /etc/qingflow-mcp/config.json)
55
+ """
56
+ paths: list[Path] = []
57
+
58
+ # 1. 环境变量
59
+ env_config = os.getenv("QINGFLOW_MCP_CONFIG_PATH")
60
+ if env_config:
61
+ paths.append(Path(env_config).expanduser())
62
+
63
+ # 2. 当前工作目录
64
+ paths.append(Path.cwd() / "qingflow-mcp.config.json")
65
+
66
+ # 3. MCP home 目录
67
+ paths.append(get_mcp_home() / "config.json")
68
+
69
+ # 4. 系统级配置 (仅非 Windows)
70
+ if os.name != "nt":
71
+ paths.append(Path("/etc/qingflow-mcp/config.json"))
72
+
73
+ return paths
74
+
75
+
76
+ def load_config_file() -> dict[str, Any]:
77
+ """
78
+ 加载第一个存在的配置文件
79
+
80
+ Returns:
81
+ 配置字典,如果没有找到配置文件则返回空字典
82
+ """
83
+ for path in get_config_file_paths():
84
+ if path.exists():
85
+ try:
86
+ with open(path, "r", encoding="utf-8") as f:
87
+ content = f.read()
88
+ # 移除 JSON 注释 (简单的行注释处理)
89
+ lines = []
90
+ for line in content.split("\n"):
91
+ stripped = line.strip()
92
+ if not stripped.startswith("//") and not stripped.startswith("#"):
93
+ lines.append(line)
94
+ return json.loads("\n".join(lines))
95
+ except (json.JSONDecodeError, IOError) as e:
96
+ # 配置文件存在但读取失败,记录警告但不中断
97
+ print(f"Warning: Failed to load config from {path}: {e}")
98
+ continue
99
+ return {}
100
+
101
+
102
+ def get_config_value(key: str, env_var: str | None = None, default: Any = None) -> Any:
103
+ """
104
+ 获取配置值,优先级:环境变量 > 配置文件 > 默认值
105
+
106
+ Args:
107
+ key: 配置文件中的键名 (支持点号分隔的嵌套键,如 "profiles.default.name")
108
+ env_var: 环境变量名
109
+ default: 默认值
110
+
111
+ Returns:
112
+ 配置值
113
+ """
114
+ # 1. 环境变量
115
+ if env_var:
116
+ env_value = os.getenv(env_var)
117
+ if env_value is not None:
118
+ return env_value
119
+
120
+ # 2. 配置文件
121
+ config = load_config_file()
122
+ keys = key.split(".")
123
+ value = config
124
+ for k in keys:
125
+ if isinstance(value, dict) and k in value:
126
+ value = value[k]
127
+ else:
128
+ value = None
129
+ break
130
+
131
+ if value is not None:
132
+ return value
133
+
134
+ # 3. 默认值
135
+ return default
136
+
137
+
138
+ def get_default_base_url() -> str | None:
139
+ """获取默认的 Qingflow 后端地址"""
140
+ value = get_config_value(
141
+ "default_base_url",
142
+ env_var="QINGFLOW_MCP_DEFAULT_BASE_URL",
143
+ default=DEFAULT_BASE_URL
144
+ )
145
+ return normalize_base_url(value) if value else None
146
+
147
+
148
+ def get_default_qf_version() -> str | None:
149
+ """获取默认的 qfVersion 路由值"""
150
+ value = get_config_value(
151
+ "default_qf_version",
152
+ env_var="QINGFLOW_MCP_DEFAULT_QF_VERSION",
153
+ default=None,
154
+ )
155
+ if value is None:
156
+ return None
157
+ normalized = str(value).strip()
158
+ return normalized or None
159
+
160
+
161
+ def get_feedback_qsource_token() -> str | None:
162
+ """获取反馈 q-source 被动入口 token"""
163
+ value = get_config_value(
164
+ "feedback.qsource_token",
165
+ env_var="QINGFLOW_MCP_FEEDBACK_QSOURCE_TOKEN",
166
+ default=DEFAULT_FEEDBACK_QSOURCE_TOKEN,
167
+ )
168
+ if value is None:
169
+ return None
170
+ normalized = str(value).strip()
171
+ return normalized or None
172
+
173
+
174
+ def get_feedback_base_url() -> str | None:
175
+ """获取反馈 q-source 使用的 base URL"""
176
+ value = get_config_value(
177
+ "feedback.base_url",
178
+ env_var="QINGFLOW_MCP_FEEDBACK_BASE_URL",
179
+ default=None,
180
+ )
181
+ if value is None:
182
+ return get_default_base_url()
183
+ normalized = normalize_base_url(value)
184
+ return normalized or get_default_base_url()
185
+
186
+
187
+ def get_feedback_app_key() -> str:
188
+ """获取内部反馈表 app_key"""
189
+ value = get_config_value(
190
+ "feedback.app_key",
191
+ env_var="QINGFLOW_MCP_FEEDBACK_APP_KEY",
192
+ default=DEFAULT_FEEDBACK_APP_KEY,
193
+ )
194
+ normalized = str(value or "").strip()
195
+ return normalized or DEFAULT_FEEDBACK_APP_KEY
196
+
197
+
198
+ def get_timeout_seconds() -> float:
199
+ """获取 HTTP 超时秒数"""
200
+ value = get_config_value(
201
+ "timeout_seconds",
202
+ env_var="QINGFLOW_MCP_TIMEOUT_SECONDS",
203
+ default=DEFAULT_TIMEOUT_SECONDS
204
+ )
205
+ try:
206
+ return float(value)
207
+ except (ValueError, TypeError):
208
+ return DEFAULT_TIMEOUT_SECONDS
209
+
210
+
211
+ def get_log_level() -> str:
212
+ """获取日志级别"""
213
+ return get_config_value(
214
+ "log_level",
215
+ env_var="QINGFLOW_MCP_LOG_LEVEL",
216
+ default="INFO"
217
+ )
218
+
219
+
220
+ def get_credit_meter_enabled() -> bool:
221
+ value = get_config_value(
222
+ "credit_meter.enabled",
223
+ env_var="QINGFLOW_MCP_CREDIT_METER_ENABLED",
224
+ default="true",
225
+ )
226
+ normalized = str(value or "").strip().lower()
227
+ return normalized in {"1", "true", "yes", "on"}
228
+
229
+
230
+ def get_credit_usage_base_url() -> str | None:
231
+ value = get_config_value(
232
+ "credit_meter.apaas.base_url",
233
+ env_var="QINGFLOW_MCP_CREDIT_APAAS_BASE_URL",
234
+ default=None,
235
+ )
236
+ normalized = normalize_base_url(value)
237
+ return normalized or None
238
+
239
+
240
+ def get_credit_usage_path() -> str:
241
+ value = get_config_value(
242
+ "credit_meter.apaas.path",
243
+ env_var="QINGFLOW_MCP_CREDIT_APAAS_PATH",
244
+ default=DEFAULT_CREDIT_USAGE_RECORD_PATH,
245
+ )
246
+ normalized = str(value or "").strip()
247
+ return normalized or DEFAULT_CREDIT_USAGE_RECORD_PATH
248
+
249
+
250
+ def get_repository_default_group() -> str | None:
251
+ value = get_config_value(
252
+ "repository.default_group",
253
+ env_var="QINGFLOW_MCP_REPOSITORY_DEFAULT_GROUP",
254
+ default=None,
255
+ )
256
+ normalized = str(value or "").strip()
257
+ return normalized or None
258
+
259
+
260
+ def get_repository_git_remote_template() -> str:
261
+ value = get_config_value(
262
+ "repository.git_remote_template",
263
+ env_var="QINGFLOW_MCP_REPOSITORY_GIT_REMOTE_TEMPLATE",
264
+ default=DEFAULT_REPOSITORY_GIT_REMOTE_TEMPLATE,
265
+ )
266
+ normalized = str(value or "").strip()
267
+ return normalized or DEFAULT_REPOSITORY_GIT_REMOTE_TEMPLATE
268
+
269
+
270
+ def get_repository_preview_address_template() -> str:
271
+ value = get_config_value(
272
+ "repository.preview_address_template",
273
+ env_var="QINGFLOW_MCP_REPOSITORY_PREVIEW_ADDRESS_TEMPLATE",
274
+ default=DEFAULT_REPOSITORY_PREVIEW_ADDRESS_TEMPLATE,
275
+ )
276
+ normalized = str(value or "").strip()
277
+ return normalized or DEFAULT_REPOSITORY_PREVIEW_ADDRESS_TEMPLATE
278
+
279
+
280
+ def get_repository_develop_branch() -> str:
281
+ value = get_config_value(
282
+ "repository.develop_branch",
283
+ env_var="QINGFLOW_MCP_REPOSITORY_DEVELOP_BRANCH",
284
+ default=DEFAULT_REPOSITORY_DEVELOP_BRANCH,
285
+ )
286
+ normalized = str(value or "").strip()
287
+ return normalized or DEFAULT_REPOSITORY_DEVELOP_BRANCH
288
+
289
+
290
+ def get_repository_prod_branch() -> str:
291
+ value = get_config_value(
292
+ "repository.prod_branch",
293
+ env_var="QINGFLOW_MCP_REPOSITORY_PROD_BRANCH",
294
+ default=DEFAULT_REPOSITORY_PROD_BRANCH,
295
+ )
296
+ normalized = str(value or "").strip()
297
+ return normalized or DEFAULT_REPOSITORY_PROD_BRANCH
298
+
299
+
300
+ def get_repository_author_name() -> str:
301
+ value = get_config_value(
302
+ "repository.author_name",
303
+ env_var="QINGFLOW_MCP_REPOSITORY_AUTHOR_NAME",
304
+ default=DEFAULT_REPOSITORY_AUTHOR_NAME,
305
+ )
306
+ normalized = str(value or "").strip()
307
+ return normalized or DEFAULT_REPOSITORY_AUTHOR_NAME
308
+
309
+
310
+ def get_repository_author_email() -> str:
311
+ value = get_config_value(
312
+ "repository.author_email",
313
+ env_var="QINGFLOW_MCP_REPOSITORY_AUTHOR_EMAIL",
314
+ default=DEFAULT_REPOSITORY_AUTHOR_EMAIL,
315
+ )
316
+ normalized = str(value or "").strip()
317
+ return normalized or DEFAULT_REPOSITORY_AUTHOR_EMAIL
318
+
319
+
320
+ def get_repository_internal_base_url() -> str | None:
321
+ value = get_config_value(
322
+ "repository.internal_base_url",
323
+ env_var="QINGFLOW_MCP_REPOSITORY_INTERNAL_BASE_URL",
324
+ default=None,
325
+ )
326
+ if value is None:
327
+ return None
328
+ normalized = normalize_base_url(str(value).strip())
329
+ return normalized or None
330
+
331
+
332
+ def get_repository_internal_share_token() -> str | None:
333
+ value = get_config_value(
334
+ "repository.internal_share_token",
335
+ env_var="QINGFLOW_MCP_REPOSITORY_INTERNAL_SHARE_TOKEN",
336
+ default=None,
337
+ )
338
+ normalized = str(value or "").strip()
339
+ return normalized or None
340
+
341
+
342
+ def get_repository_internal_share_token_key() -> str:
343
+ value = get_config_value(
344
+ "repository.internal_share_token_key",
345
+ env_var="QINGFLOW_MCP_REPOSITORY_INTERNAL_SHARE_TOKEN_KEY",
346
+ default=DEFAULT_REPOSITORY_INTERNAL_SHARE_TOKEN_KEY,
347
+ )
348
+ normalized = str(value or "").strip()
349
+ return normalized or DEFAULT_REPOSITORY_INTERNAL_SHARE_TOKEN_KEY
350
+
351
+
352
+ def get_repository_generate_default_agent_id() -> int | None:
353
+ value = get_config_value(
354
+ "repository.generate.default_agent_id",
355
+ env_var="QINGFLOW_MCP_REPOSITORY_GENERATE_DEFAULT_AGENT_ID",
356
+ default=None,
357
+ )
358
+ if value is None:
359
+ return None
360
+ try:
361
+ return int(str(value).strip())
362
+ except (TypeError, ValueError):
363
+ return None
364
+
365
+
366
+ def get_repository_generate_default_route_prefix() -> str | None:
367
+ value = get_config_value(
368
+ "repository.generate.default_route_prefix",
369
+ env_var="QINGFLOW_MCP_REPOSITORY_GENERATE_DEFAULT_ROUTE_PREFIX",
370
+ default=None,
371
+ )
372
+ normalized = str(value or "").strip()
373
+ return normalized or None
374
+
375
+
376
+ def get_repository_generate_default_token_name() -> str | None:
377
+ value = get_config_value(
378
+ "repository.generate.default_token_name",
379
+ env_var="QINGFLOW_MCP_REPOSITORY_GENERATE_DEFAULT_TOKEN_NAME",
380
+ default=None,
381
+ )
382
+ normalized = str(value or "").strip()
383
+ return normalized or None
384
+
385
+
386
+ def normalize_base_url(base_url: str | None) -> str | None:
387
+ """规范化 base URL"""
388
+ if base_url is None:
389
+ return None
390
+ normalized = base_url.strip()
391
+ if not normalized:
392
+ return None
393
+ normalized = normalized.rstrip("/")
394
+ try:
395
+ parsed = urlsplit(normalized)
396
+ except ValueError:
397
+ return normalized
398
+ if not parsed.scheme or not parsed.netloc:
399
+ return normalized
400
+
401
+ hostname = parsed.hostname or ""
402
+ if hostname.lower() == "www.qingflow.com":
403
+ netloc = "qingflow.com"
404
+ if parsed.port is not None:
405
+ netloc = f"{netloc}:{parsed.port}"
406
+ normalized = urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
407
+ return normalized
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass
5
+
6
+ from .json_types import JSONObject, JSONScalar
7
+
8
+
9
+ INVALID_TOKEN_MARKERS = (
10
+ "invalid token",
11
+ "token invalid",
12
+ "token失效",
13
+ "无效token",
14
+ "登录失效",
15
+ "login token invalid",
16
+ "access token invalid",
17
+ )
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class QingflowApiError(Exception):
22
+ category: str
23
+ message: str
24
+ backend_code: JSONScalar = None
25
+ request_id: str | None = None
26
+ http_status: int | None = None
27
+ details: JSONObject | None = None
28
+
29
+ def to_dict(self) -> JSONObject:
30
+ return asdict(self)
31
+
32
+ def as_json(self) -> str:
33
+ return json.dumps(self.to_dict(), ensure_ascii=False)
34
+
35
+ def __str__(self) -> str:
36
+ return self.as_json()
37
+
38
+ def looks_like_invalid_token(self) -> bool:
39
+ text = self.message.lower()
40
+ return any(marker in text for marker in INVALID_TOKEN_MARKERS)
41
+
42
+ @classmethod
43
+ def auth_required(cls, profile: str) -> "QingflowApiError":
44
+ return cls(
45
+ category="auth",
46
+ message=f"Profile '{profile}' is not logged in. Run auth login or auth_use_credential first.",
47
+ )
48
+
49
+ @classmethod
50
+ def workspace_not_selected(cls, profile: str) -> "QingflowApiError":
51
+ return cls(
52
+ category="workspace",
53
+ message=f"WORKSPACE_NOT_SELECTED: profile '{profile}' has no workspace from auth context. Re-run auth login or auth_use_credential.",
54
+ )
55
+
56
+ @classmethod
57
+ def config_error(cls, message: str) -> "QingflowApiError":
58
+ return cls(category="config", message=message)
59
+
60
+ @classmethod
61
+ def not_supported(cls, message: str) -> "QingflowApiError":
62
+ return cls(category="not_supported", message=message)
63
+
64
+
65
+ def raise_tool_error(error: QingflowApiError) -> None:
66
+ raise RuntimeError(error.as_json())
@@ -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
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .config import get_mcp_home
11
+
12
+
13
+ def _utc_now() -> datetime:
14
+ return datetime.now(timezone.utc)
15
+
16
+
17
+ def _parse_utc(value: Any) -> datetime | None:
18
+ if not isinstance(value, str) or not value.strip():
19
+ return None
20
+ normalized = value.strip().replace("Z", "+00:00")
21
+ try:
22
+ parsed = datetime.fromisoformat(normalized)
23
+ except ValueError:
24
+ return None
25
+ if parsed.tzinfo is None:
26
+ return parsed.replace(tzinfo=timezone.utc)
27
+ return parsed.astimezone(timezone.utc)
28
+
29
+
30
+ def _json_safe_key(value: str) -> str:
31
+ keep = []
32
+ for char in value:
33
+ if char.isalnum() or char in {"-", "_"}:
34
+ keep.append(char)
35
+ else:
36
+ keep.append("_")
37
+ result = "".join(keep).strip("_")
38
+ return result or "entry"
39
+
40
+
41
+ def _store_dir(env_var: str, default_name: str) -> Path:
42
+ custom = os.getenv(env_var)
43
+ if custom:
44
+ return Path(custom).expanduser()
45
+ return get_mcp_home() / default_name
46
+
47
+
48
+ @dataclass(slots=True)
49
+ class _JsonEntryStore:
50
+ base_dir: Path
51
+ ttl: timedelta
52
+
53
+ def __post_init__(self) -> None:
54
+ self.base_dir.mkdir(parents=True, exist_ok=True)
55
+ self.prune()
56
+
57
+ def put(self, entry_id: str, payload: dict[str, Any]) -> None:
58
+ data = dict(payload)
59
+ data["id"] = entry_id
60
+ data["updated_at"] = _utc_now().isoformat()
61
+ self._path(entry_id).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
62
+
63
+ def get(self, entry_id: str) -> dict[str, Any] | None:
64
+ path = self._path(entry_id)
65
+ if not path.exists():
66
+ return None
67
+ try:
68
+ payload = json.loads(path.read_text(encoding="utf-8"))
69
+ except (OSError, json.JSONDecodeError):
70
+ path.unlink(missing_ok=True)
71
+ return None
72
+ created_at = _parse_utc(payload.get("created_at")) or _parse_utc(payload.get("updated_at"))
73
+ if created_at is None or _utc_now() - created_at > self.ttl:
74
+ path.unlink(missing_ok=True)
75
+ return None
76
+ return payload
77
+
78
+ def prune(self) -> None:
79
+ for path in self.base_dir.glob("*.json"):
80
+ try:
81
+ payload = json.loads(path.read_text(encoding="utf-8"))
82
+ except (OSError, json.JSONDecodeError):
83
+ path.unlink(missing_ok=True)
84
+ continue
85
+ created_at = _parse_utc(payload.get("created_at")) or _parse_utc(payload.get("updated_at"))
86
+ if created_at is None or _utc_now() - created_at > self.ttl:
87
+ path.unlink(missing_ok=True)
88
+
89
+ def list(self) -> list[dict[str, Any]]:
90
+ entries: list[dict[str, Any]] = []
91
+ self.prune()
92
+ for path in self.base_dir.glob("*.json"):
93
+ try:
94
+ payload = json.loads(path.read_text(encoding="utf-8"))
95
+ except (OSError, json.JSONDecodeError):
96
+ continue
97
+ created_at = _parse_utc(payload.get("created_at")) or _parse_utc(payload.get("updated_at"))
98
+ if created_at is None:
99
+ continue
100
+ entries.append(payload)
101
+ entries.sort(key=lambda item: item.get("created_at") or item.get("updated_at") or "", reverse=True)
102
+ return entries
103
+
104
+ def _path(self, entry_id: str) -> Path:
105
+ return self.base_dir / f"{_json_safe_key(entry_id)}.json"
106
+
107
+
108
+ class ImportVerificationStore(_JsonEntryStore):
109
+ def __init__(self, base_dir: Path | None = None, *, ttl_seconds: int = 3600) -> None:
110
+ super().__init__(
111
+ base_dir=base_dir or _store_dir("QINGFLOW_MCP_IMPORT_VERIFY_HOME", "import-verifications"),
112
+ ttl=timedelta(seconds=ttl_seconds),
113
+ )
114
+
115
+
116
+ class ImportJobStore(_JsonEntryStore):
117
+ def __init__(self, base_dir: Path | None = None, *, ttl_seconds: int = 24 * 3600) -> None:
118
+ super().__init__(
119
+ base_dir=base_dir or _store_dir("QINGFLOW_MCP_IMPORT_JOB_HOME", "import-jobs"),
120
+ ttl=timedelta(seconds=ttl_seconds),
121
+ )
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol, Any
4
+
5
+ # Use Any for JSON types to avoid Pydantic recursion issues
6
+ # These are used for MCP tool signatures where exact typing is less critical
7
+ JSONScalar = Any
8
+ JSONValue = Any
9
+ JSONObject = dict[str, Any]
10
+ JSONArray = list[Any]
11
+
12
+
13
+ class KeyringBackend(Protocol):
14
+ def set_password(self, service: str, key: str, value: str) -> None: ...
15
+
16
+ def get_password(self, service: str, key: str) -> str | None: ...
17
+
18
+ def delete_password(self, service: str, key: str) -> None: ...