@josephyan/qingflow-app-user-mcp 0.1.0-beta.9
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 +21 -0
- package/docs/local-agent-install.md +228 -0
- package/entry_point.py +13 -0
- package/npm/bin/qingflow-app-user-mcp.mjs +7 -0
- package/npm/lib/runtime.mjs +146 -0
- package/npm/scripts/postinstall.mjs +12 -0
- package/package.json +33 -0
- package/pyproject.toml +64 -0
- package/qingflow-app-user-mcp +15 -0
- package/src/qingflow_mcp/__init__.py +5 -0
- package/src/qingflow_mcp/__main__.py +5 -0
- package/src/qingflow_mcp/backend_client.py +336 -0
- package/src/qingflow_mcp/config.py +182 -0
- package/src/qingflow_mcp/errors.py +66 -0
- package/src/qingflow_mcp/json_types.py +18 -0
- package/src/qingflow_mcp/list_type_labels.py +52 -0
- package/src/qingflow_mcp/server.py +70 -0
- package/src/qingflow_mcp/server_app_builder.py +352 -0
- package/src/qingflow_mcp/server_app_user.py +334 -0
- package/src/qingflow_mcp/session_store.py +249 -0
- package/src/qingflow_mcp/solution/__init__.py +6 -0
- package/src/qingflow_mcp/solution/build_assembly_store.py +137 -0
- package/src/qingflow_mcp/solution/compiler/__init__.py +265 -0
- package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +456 -0
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
- package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
- package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
- package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
- package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +134 -0
- package/src/qingflow_mcp/solution/design_session.py +222 -0
- package/src/qingflow_mcp/solution/design_store.py +100 -0
- package/src/qingflow_mcp/solution/executor.py +2065 -0
- package/src/qingflow_mcp/solution/normalizer.py +23 -0
- package/src/qingflow_mcp/solution/run_store.py +221 -0
- package/src/qingflow_mcp/solution/spec_models.py +853 -0
- package/src/qingflow_mcp/tools/__init__.py +1 -0
- package/src/qingflow_mcp/tools/app_tools.py +406 -0
- package/src/qingflow_mcp/tools/approval_tools.py +498 -0
- package/src/qingflow_mcp/tools/auth_tools.py +514 -0
- package/src/qingflow_mcp/tools/base.py +81 -0
- package/src/qingflow_mcp/tools/directory_tools.py +476 -0
- package/src/qingflow_mcp/tools/file_tools.py +375 -0
- package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
- package/src/qingflow_mcp/tools/package_tools.py +142 -0
- package/src/qingflow_mcp/tools/portal_tools.py +100 -0
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +235 -0
- package/src/qingflow_mcp/tools/record_tools.py +4307 -0
- package/src/qingflow_mcp/tools/role_tools.py +94 -0
- package/src/qingflow_mcp/tools/solution_tools.py +2680 -0
- package/src/qingflow_mcp/tools/task_tools.py +692 -0
- package/src/qingflow_mcp/tools/view_tools.py +280 -0
- package/src/qingflow_mcp/tools/workflow_tools.py +238 -0
- package/src/qingflow_mcp/tools/workspace_tools.py +170 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .config import DEFAULT_USER_AGENT, get_default_qf_version, get_timeout_seconds, normalize_base_url
|
|
9
|
+
from .errors import QingflowApiError
|
|
10
|
+
from .json_types import JSONObject, JSONScalar, JSONValue
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class BackendRequestContext:
|
|
15
|
+
base_url: str
|
|
16
|
+
token: str
|
|
17
|
+
ws_id: int | None
|
|
18
|
+
qf_request_id: str | None = None
|
|
19
|
+
qf_version: str | None = None
|
|
20
|
+
qf_version_source: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class BackendResponse:
|
|
25
|
+
data: JSONValue
|
|
26
|
+
headers: dict[str, str]
|
|
27
|
+
request_id: str
|
|
28
|
+
http_status: int
|
|
29
|
+
qf_response_version: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BackendClient:
|
|
33
|
+
def __init__(self, timeout: float | None = None, client: httpx.Client | None = None) -> None:
|
|
34
|
+
self._owns_client = client is None
|
|
35
|
+
self._default_qf_version = get_default_qf_version()
|
|
36
|
+
self._client = client or httpx.Client(
|
|
37
|
+
timeout=timeout or get_timeout_seconds(),
|
|
38
|
+
follow_redirects=True,
|
|
39
|
+
trust_env=False,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def close(self) -> None:
|
|
43
|
+
if self._owns_client:
|
|
44
|
+
self._client.close()
|
|
45
|
+
|
|
46
|
+
def public_request(
|
|
47
|
+
self,
|
|
48
|
+
method: str,
|
|
49
|
+
base_url: str,
|
|
50
|
+
path: str,
|
|
51
|
+
*,
|
|
52
|
+
params: JSONObject | None = None,
|
|
53
|
+
json_body: JSONValue = None,
|
|
54
|
+
unwrap: bool = True,
|
|
55
|
+
qf_version: str | None = None,
|
|
56
|
+
) -> JSONValue:
|
|
57
|
+
return self.public_request_with_meta(
|
|
58
|
+
method,
|
|
59
|
+
base_url,
|
|
60
|
+
path,
|
|
61
|
+
params=params,
|
|
62
|
+
json_body=json_body,
|
|
63
|
+
unwrap=unwrap,
|
|
64
|
+
qf_version=qf_version,
|
|
65
|
+
).data
|
|
66
|
+
|
|
67
|
+
def public_request_with_meta(
|
|
68
|
+
self,
|
|
69
|
+
method: str,
|
|
70
|
+
base_url: str,
|
|
71
|
+
path: str,
|
|
72
|
+
*,
|
|
73
|
+
params: JSONObject | None = None,
|
|
74
|
+
json_body: JSONValue = None,
|
|
75
|
+
unwrap: bool = True,
|
|
76
|
+
qf_version: str | None = None,
|
|
77
|
+
) -> BackendResponse:
|
|
78
|
+
return self._request_with_meta(
|
|
79
|
+
method,
|
|
80
|
+
self._build_url(base_url, path),
|
|
81
|
+
params=params,
|
|
82
|
+
json_body=json_body,
|
|
83
|
+
headers=self._base_headers(None, None, qf_version=qf_version),
|
|
84
|
+
unwrap=unwrap,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def request(
|
|
88
|
+
self,
|
|
89
|
+
method: str,
|
|
90
|
+
context: BackendRequestContext,
|
|
91
|
+
path: str,
|
|
92
|
+
*,
|
|
93
|
+
params: JSONObject | None = None,
|
|
94
|
+
json_body: JSONValue = None,
|
|
95
|
+
unwrap: bool = True,
|
|
96
|
+
) -> JSONValue:
|
|
97
|
+
return self.request_with_meta(
|
|
98
|
+
method,
|
|
99
|
+
context,
|
|
100
|
+
path,
|
|
101
|
+
params=params,
|
|
102
|
+
json_body=json_body,
|
|
103
|
+
unwrap=unwrap,
|
|
104
|
+
).data
|
|
105
|
+
|
|
106
|
+
def request_with_meta(
|
|
107
|
+
self,
|
|
108
|
+
method: str,
|
|
109
|
+
context: BackendRequestContext,
|
|
110
|
+
path: str,
|
|
111
|
+
*,
|
|
112
|
+
params: JSONObject | None = None,
|
|
113
|
+
json_body: JSONValue = None,
|
|
114
|
+
unwrap: bool = True,
|
|
115
|
+
) -> BackendResponse:
|
|
116
|
+
return self._request_with_meta(
|
|
117
|
+
method,
|
|
118
|
+
self._build_url(context.base_url, path),
|
|
119
|
+
params=params,
|
|
120
|
+
json_body=json_body,
|
|
121
|
+
headers=self._base_headers(
|
|
122
|
+
context.token,
|
|
123
|
+
context.ws_id,
|
|
124
|
+
context.qf_request_id,
|
|
125
|
+
qf_version=context.qf_version,
|
|
126
|
+
),
|
|
127
|
+
unwrap=unwrap,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def describe_route(self, context: BackendRequestContext) -> JSONObject:
|
|
131
|
+
qf_version, source = self._resolve_qf_version(context.qf_version)
|
|
132
|
+
if context.qf_version is not None and context.qf_version_source:
|
|
133
|
+
source = context.qf_version_source
|
|
134
|
+
return {
|
|
135
|
+
"base_url": normalize_base_url(context.base_url) or context.base_url,
|
|
136
|
+
"qf_version": qf_version,
|
|
137
|
+
"qf_version_source": source,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def upload_binary(
|
|
141
|
+
self,
|
|
142
|
+
url: str,
|
|
143
|
+
content: bytes,
|
|
144
|
+
*,
|
|
145
|
+
content_type: str | None = None,
|
|
146
|
+
headers: dict[str, str] | None = None,
|
|
147
|
+
) -> JSONObject:
|
|
148
|
+
request_headers = dict(headers or {})
|
|
149
|
+
if content_type:
|
|
150
|
+
request_headers.setdefault("Content-Type", content_type)
|
|
151
|
+
try:
|
|
152
|
+
response = self._client.put(url, content=content, headers=request_headers or None)
|
|
153
|
+
except httpx.RequestError as exc:
|
|
154
|
+
raise QingflowApiError(category="network", message=str(exc))
|
|
155
|
+
if response.status_code >= 400:
|
|
156
|
+
raise QingflowApiError(
|
|
157
|
+
category="http",
|
|
158
|
+
message=self._extract_message(response.text) or f"HTTP {response.status_code}",
|
|
159
|
+
http_status=response.status_code,
|
|
160
|
+
)
|
|
161
|
+
return {
|
|
162
|
+
"status_code": response.status_code,
|
|
163
|
+
"headers": dict(response.headers),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
def upload_form_file(
|
|
167
|
+
self,
|
|
168
|
+
url: str,
|
|
169
|
+
*,
|
|
170
|
+
form_fields: dict[str, str],
|
|
171
|
+
file_field: str,
|
|
172
|
+
file_name: str,
|
|
173
|
+
content: bytes,
|
|
174
|
+
content_type: str | None = None,
|
|
175
|
+
headers: dict[str, str] | None = None,
|
|
176
|
+
) -> JSONObject:
|
|
177
|
+
try:
|
|
178
|
+
response = self._client.post(
|
|
179
|
+
url,
|
|
180
|
+
data=form_fields,
|
|
181
|
+
files={file_field: (file_name, content, content_type or "application/octet-stream")},
|
|
182
|
+
headers=headers or None,
|
|
183
|
+
)
|
|
184
|
+
except httpx.RequestError as exc:
|
|
185
|
+
raise QingflowApiError(category="network", message=str(exc))
|
|
186
|
+
if response.status_code >= 400:
|
|
187
|
+
raise QingflowApiError(
|
|
188
|
+
category="http",
|
|
189
|
+
message=self._extract_message(response.text) or f"HTTP {response.status_code}",
|
|
190
|
+
http_status=response.status_code,
|
|
191
|
+
)
|
|
192
|
+
body: JSONValue = None
|
|
193
|
+
if response.content:
|
|
194
|
+
try:
|
|
195
|
+
body = response.json()
|
|
196
|
+
except ValueError:
|
|
197
|
+
body = response.text
|
|
198
|
+
return {
|
|
199
|
+
"status_code": response.status_code,
|
|
200
|
+
"headers": dict(response.headers),
|
|
201
|
+
"body": body,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
def _request_with_meta(
|
|
205
|
+
self,
|
|
206
|
+
method: str,
|
|
207
|
+
url: str,
|
|
208
|
+
*,
|
|
209
|
+
params: JSONObject | None,
|
|
210
|
+
json_body: JSONValue,
|
|
211
|
+
headers: dict[str, str],
|
|
212
|
+
unwrap: bool,
|
|
213
|
+
) -> BackendResponse:
|
|
214
|
+
attempts = 2 if method.upper() == "GET" else 1
|
|
215
|
+
last_error: QingflowApiError | None = None
|
|
216
|
+
for _ in range(attempts):
|
|
217
|
+
try:
|
|
218
|
+
response = self._client.request(method.upper(), url, params=params, json=json_body, headers=headers)
|
|
219
|
+
parsed = self._parse_response(response, headers["Qf-Request-Id"], unwrap=unwrap)
|
|
220
|
+
return BackendResponse(
|
|
221
|
+
data=parsed,
|
|
222
|
+
headers=dict(response.headers),
|
|
223
|
+
request_id=headers["Qf-Request-Id"],
|
|
224
|
+
http_status=response.status_code,
|
|
225
|
+
qf_response_version=self._extract_response_qf_version(response.headers),
|
|
226
|
+
)
|
|
227
|
+
except httpx.RequestError as exc:
|
|
228
|
+
last_error = QingflowApiError(category="network", message=str(exc), request_id=headers["Qf-Request-Id"])
|
|
229
|
+
assert last_error is not None
|
|
230
|
+
raise last_error
|
|
231
|
+
|
|
232
|
+
def _parse_response(self, response: httpx.Response, request_id: str, *, unwrap: bool) -> JSONValue:
|
|
233
|
+
payload: JSONValue
|
|
234
|
+
try:
|
|
235
|
+
payload = response.json()
|
|
236
|
+
except ValueError:
|
|
237
|
+
payload = response.text
|
|
238
|
+
if response.status_code >= 400:
|
|
239
|
+
raise QingflowApiError(
|
|
240
|
+
category="http",
|
|
241
|
+
message=self._extract_message(payload) or f"HTTP {response.status_code}",
|
|
242
|
+
backend_code=self._extract_code(payload),
|
|
243
|
+
request_id=request_id,
|
|
244
|
+
http_status=response.status_code,
|
|
245
|
+
)
|
|
246
|
+
if not unwrap:
|
|
247
|
+
return payload
|
|
248
|
+
return self._unwrap_payload(payload, request_id, response.status_code)
|
|
249
|
+
|
|
250
|
+
def _unwrap_payload(self, payload: JSONValue, request_id: str, http_status: int) -> JSONValue:
|
|
251
|
+
if not isinstance(payload, dict):
|
|
252
|
+
return payload
|
|
253
|
+
if "success" in payload:
|
|
254
|
+
if not bool(payload.get("success")):
|
|
255
|
+
raise QingflowApiError(
|
|
256
|
+
category="backend",
|
|
257
|
+
message=self._extract_message(payload) or "Qingflow request failed",
|
|
258
|
+
backend_code=self._extract_code(payload),
|
|
259
|
+
request_id=request_id,
|
|
260
|
+
http_status=http_status,
|
|
261
|
+
)
|
|
262
|
+
return self._extract_success_data(payload)
|
|
263
|
+
code = self._extract_code(payload)
|
|
264
|
+
if code not in (None, 0, "0"):
|
|
265
|
+
raise QingflowApiError(
|
|
266
|
+
category="backend",
|
|
267
|
+
message=self._extract_message(payload) or "Qingflow request failed",
|
|
268
|
+
backend_code=code,
|
|
269
|
+
request_id=request_id,
|
|
270
|
+
http_status=http_status,
|
|
271
|
+
)
|
|
272
|
+
if code in (0, "0"):
|
|
273
|
+
return self._extract_success_data(payload)
|
|
274
|
+
return payload
|
|
275
|
+
|
|
276
|
+
def _extract_success_data(self, payload: JSONObject) -> JSONValue:
|
|
277
|
+
for key in ("result", "data", "page", "obj"):
|
|
278
|
+
if key in payload:
|
|
279
|
+
return payload[key]
|
|
280
|
+
return payload
|
|
281
|
+
|
|
282
|
+
def _extract_message(self, payload: JSONValue) -> str | None:
|
|
283
|
+
if not isinstance(payload, dict):
|
|
284
|
+
return str(payload) if payload else None
|
|
285
|
+
for key in ("message", "msg", "errMsg", "error", "detail"):
|
|
286
|
+
value = payload.get(key)
|
|
287
|
+
if value:
|
|
288
|
+
return str(value)
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
def _extract_code(self, payload: JSONValue) -> JSONScalar:
|
|
292
|
+
if isinstance(payload, dict):
|
|
293
|
+
return payload.get("code", payload.get("errCode"))
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
def _base_headers(
|
|
297
|
+
self,
|
|
298
|
+
token: str | None,
|
|
299
|
+
ws_id: int | None,
|
|
300
|
+
request_id: str | None = None,
|
|
301
|
+
*,
|
|
302
|
+
qf_version: str | None = None,
|
|
303
|
+
) -> dict[str, str]:
|
|
304
|
+
headers = {
|
|
305
|
+
"User-Agent": DEFAULT_USER_AGENT,
|
|
306
|
+
"Qf-Request-Id": request_id or str(uuid4()),
|
|
307
|
+
}
|
|
308
|
+
resolved_qf_version, _ = self._resolve_qf_version(qf_version)
|
|
309
|
+
if resolved_qf_version:
|
|
310
|
+
headers["Cookie"] = f"qfVersion={resolved_qf_version}"
|
|
311
|
+
if token:
|
|
312
|
+
headers["token"] = token
|
|
313
|
+
if ws_id is not None:
|
|
314
|
+
headers["wsId"] = str(ws_id)
|
|
315
|
+
return headers
|
|
316
|
+
|
|
317
|
+
def _resolve_qf_version(self, explicit_qf_version: str | None) -> tuple[str | None, str]:
|
|
318
|
+
if explicit_qf_version is not None:
|
|
319
|
+
normalized = str(explicit_qf_version).strip() or None
|
|
320
|
+
return normalized, "context"
|
|
321
|
+
if self._default_qf_version:
|
|
322
|
+
return self._default_qf_version, "default_config"
|
|
323
|
+
return None, "unset"
|
|
324
|
+
|
|
325
|
+
def _extract_response_qf_version(self, headers: httpx.Headers) -> str | None:
|
|
326
|
+
value = headers.get("x-q-response-version")
|
|
327
|
+
if value is None:
|
|
328
|
+
return None
|
|
329
|
+
normalized = str(value).strip()
|
|
330
|
+
return normalized or None
|
|
331
|
+
|
|
332
|
+
def _build_url(self, base_url: str, path: str) -> str:
|
|
333
|
+
normalized = normalize_base_url(base_url)
|
|
334
|
+
if not normalized:
|
|
335
|
+
raise QingflowApiError.config_error("base_url is required")
|
|
336
|
+
return f"{normalized}/{path.lstrip('/')}"
|
|
@@ -0,0 +1,182 @@
|
|
|
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
|
+
|
|
16
|
+
|
|
17
|
+
def get_mcp_home() -> Path:
|
|
18
|
+
custom_home = os.getenv("QINGFLOW_MCP_HOME")
|
|
19
|
+
return Path(custom_home).expanduser() if custom_home else Path.home() / ".qingflow-mcp"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_profiles_path() -> Path:
|
|
23
|
+
return get_mcp_home() / "profiles.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_config_file_paths() -> list[Path]:
|
|
27
|
+
"""
|
|
28
|
+
获取可能的配置文件路径列表,按优先级排序:
|
|
29
|
+
1. 环境变量 QINGFLOW_MCP_CONFIG_PATH 指定的路径
|
|
30
|
+
2. 当前工作目录下的 qingflow-mcp.config.json
|
|
31
|
+
3. MCP home 目录下的 config.json
|
|
32
|
+
4. 系统级配置 (Linux/Mac: /etc/qingflow-mcp/config.json)
|
|
33
|
+
"""
|
|
34
|
+
paths: list[Path] = []
|
|
35
|
+
|
|
36
|
+
# 1. 环境变量
|
|
37
|
+
env_config = os.getenv("QINGFLOW_MCP_CONFIG_PATH")
|
|
38
|
+
if env_config:
|
|
39
|
+
paths.append(Path(env_config).expanduser())
|
|
40
|
+
|
|
41
|
+
# 2. 当前工作目录
|
|
42
|
+
paths.append(Path.cwd() / "qingflow-mcp.config.json")
|
|
43
|
+
|
|
44
|
+
# 3. MCP home 目录
|
|
45
|
+
paths.append(get_mcp_home() / "config.json")
|
|
46
|
+
|
|
47
|
+
# 4. 系统级配置 (仅非 Windows)
|
|
48
|
+
if os.name != "nt":
|
|
49
|
+
paths.append(Path("/etc/qingflow-mcp/config.json"))
|
|
50
|
+
|
|
51
|
+
return paths
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_config_file() -> dict[str, Any]:
|
|
55
|
+
"""
|
|
56
|
+
加载第一个存在的配置文件
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
配置字典,如果没有找到配置文件则返回空字典
|
|
60
|
+
"""
|
|
61
|
+
for path in get_config_file_paths():
|
|
62
|
+
if path.exists():
|
|
63
|
+
try:
|
|
64
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
65
|
+
content = f.read()
|
|
66
|
+
# 移除 JSON 注释 (简单的行注释处理)
|
|
67
|
+
lines = []
|
|
68
|
+
for line in content.split("\n"):
|
|
69
|
+
stripped = line.strip()
|
|
70
|
+
if not stripped.startswith("//") and not stripped.startswith("#"):
|
|
71
|
+
lines.append(line)
|
|
72
|
+
return json.loads("\n".join(lines))
|
|
73
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
74
|
+
# 配置文件存在但读取失败,记录警告但不中断
|
|
75
|
+
print(f"Warning: Failed to load config from {path}: {e}")
|
|
76
|
+
continue
|
|
77
|
+
return {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_config_value(key: str, env_var: str | None = None, default: Any = None) -> Any:
|
|
81
|
+
"""
|
|
82
|
+
获取配置值,优先级:环境变量 > 配置文件 > 默认值
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
key: 配置文件中的键名 (支持点号分隔的嵌套键,如 "profiles.default.name")
|
|
86
|
+
env_var: 环境变量名
|
|
87
|
+
default: 默认值
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
配置值
|
|
91
|
+
"""
|
|
92
|
+
# 1. 环境变量
|
|
93
|
+
if env_var:
|
|
94
|
+
env_value = os.getenv(env_var)
|
|
95
|
+
if env_value is not None:
|
|
96
|
+
return env_value
|
|
97
|
+
|
|
98
|
+
# 2. 配置文件
|
|
99
|
+
config = load_config_file()
|
|
100
|
+
keys = key.split(".")
|
|
101
|
+
value = config
|
|
102
|
+
for k in keys:
|
|
103
|
+
if isinstance(value, dict) and k in value:
|
|
104
|
+
value = value[k]
|
|
105
|
+
else:
|
|
106
|
+
value = None
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
if value is not None:
|
|
110
|
+
return value
|
|
111
|
+
|
|
112
|
+
# 3. 默认值
|
|
113
|
+
return default
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_default_base_url() -> str | None:
|
|
117
|
+
"""获取默认的 Qingflow 后端地址"""
|
|
118
|
+
value = get_config_value(
|
|
119
|
+
"default_base_url",
|
|
120
|
+
env_var="QINGFLOW_MCP_DEFAULT_BASE_URL",
|
|
121
|
+
default=DEFAULT_BASE_URL
|
|
122
|
+
)
|
|
123
|
+
return normalize_base_url(value) if value else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_default_qf_version() -> str | None:
|
|
127
|
+
"""获取默认的 qfVersion 路由值"""
|
|
128
|
+
value = get_config_value(
|
|
129
|
+
"default_qf_version",
|
|
130
|
+
env_var="QINGFLOW_MCP_DEFAULT_QF_VERSION",
|
|
131
|
+
default=None,
|
|
132
|
+
)
|
|
133
|
+
if value is None:
|
|
134
|
+
return None
|
|
135
|
+
normalized = str(value).strip()
|
|
136
|
+
return normalized or None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_timeout_seconds() -> float:
|
|
140
|
+
"""获取 HTTP 超时秒数"""
|
|
141
|
+
value = get_config_value(
|
|
142
|
+
"timeout_seconds",
|
|
143
|
+
env_var="QINGFLOW_MCP_TIMEOUT_SECONDS",
|
|
144
|
+
default=DEFAULT_TIMEOUT_SECONDS
|
|
145
|
+
)
|
|
146
|
+
try:
|
|
147
|
+
return float(value)
|
|
148
|
+
except (ValueError, TypeError):
|
|
149
|
+
return DEFAULT_TIMEOUT_SECONDS
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_log_level() -> str:
|
|
153
|
+
"""获取日志级别"""
|
|
154
|
+
return get_config_value(
|
|
155
|
+
"log_level",
|
|
156
|
+
env_var="QINGFLOW_MCP_LOG_LEVEL",
|
|
157
|
+
default="INFO"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def normalize_base_url(base_url: str | None) -> str | None:
|
|
162
|
+
"""规范化 base URL"""
|
|
163
|
+
if base_url is None:
|
|
164
|
+
return None
|
|
165
|
+
normalized = base_url.strip()
|
|
166
|
+
if not normalized:
|
|
167
|
+
return None
|
|
168
|
+
normalized = normalized.rstrip("/")
|
|
169
|
+
try:
|
|
170
|
+
parsed = urlsplit(normalized)
|
|
171
|
+
except ValueError:
|
|
172
|
+
return normalized
|
|
173
|
+
if not parsed.scheme or not parsed.netloc:
|
|
174
|
+
return normalized
|
|
175
|
+
|
|
176
|
+
hostname = parsed.hostname or ""
|
|
177
|
+
if hostname.lower() == "www.qingflow.com":
|
|
178
|
+
netloc = "qingflow.com"
|
|
179
|
+
if parsed.port is not None:
|
|
180
|
+
netloc = f"{netloc}:{parsed.port}"
|
|
181
|
+
normalized = urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
|
|
182
|
+
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 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 selected workspace. Run workspace_select first.",
|
|
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,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: ...
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
RECORD_LIST_TYPE_LABELS: dict[int, str] = {
|
|
5
|
+
1: "待办",
|
|
6
|
+
2: "已办",
|
|
7
|
+
3: "我发起的-已通过",
|
|
8
|
+
4: "我发起的-已拒绝",
|
|
9
|
+
5: "我发起的-草稿",
|
|
10
|
+
6: "我发起的-待完善",
|
|
11
|
+
7: "我发起的-流程中",
|
|
12
|
+
8: "数据管理-全部数据",
|
|
13
|
+
9: "数据管理-已通过",
|
|
14
|
+
10: "数据管理-已拒绝",
|
|
15
|
+
11: "数据管理-流程中",
|
|
16
|
+
12: "抄送",
|
|
17
|
+
13: "图表分享者",
|
|
18
|
+
14: "我发起的",
|
|
19
|
+
15: "数据管理-已结束",
|
|
20
|
+
16: "我发起的-已结束",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
TASK_TYPE_LABELS: dict[int, str] = {
|
|
24
|
+
1: "待办",
|
|
25
|
+
2: "我发起的",
|
|
26
|
+
3: "抄送",
|
|
27
|
+
5: "已办",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
APP_PUBLISH_STATUS_LABELS: dict[int, str] = {
|
|
31
|
+
0: "未发布",
|
|
32
|
+
1: "已发布-有修改",
|
|
33
|
+
2: "已发布",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_record_list_type_label(list_type: int | None) -> str | None:
|
|
38
|
+
if list_type is None:
|
|
39
|
+
return None
|
|
40
|
+
return RECORD_LIST_TYPE_LABELS.get(list_type)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_task_type_label(type_value: int | None) -> str | None:
|
|
44
|
+
if type_value is None:
|
|
45
|
+
return None
|
|
46
|
+
return TASK_TYPE_LABELS.get(type_value)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_app_publish_status_label(status: int | None) -> str | None:
|
|
50
|
+
if status is None:
|
|
51
|
+
return None
|
|
52
|
+
return APP_PUBLISH_STATUS_LABELS.get(status)
|