@qingflow-tech/qingflow-cli 1.1.47 → 1.1.48-beta.1
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 +3 -3
- package/npm/lib/runtime.mjs +29 -13
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/SKILL.md +37 -1
- package/skills/qingflow-common/SKILL.md +7 -1
- package/src/qingflow_mcp/builder_facade/service.py +119 -1
- package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
- package/src/qingflow_mcp/cli/commands/auth.py +8 -0
- package/src/qingflow_mcp/cli/commands/data.py +134 -0
- package/src/qingflow_mcp/cli/context.py +6 -0
- package/src/qingflow_mcp/cli/formatters.py +163 -3
- package/src/qingflow_mcp/cli/main.py +4 -1
- package/src/qingflow_mcp/config.py +39 -0
- package/src/qingflow_mcp/public_surface.py +7 -0
- package/src/qingflow_mcp/resource_uri.py +241 -0
- package/src/qingflow_mcp/response_trim.py +10 -2
- package/src/qingflow_mcp/server.py +4 -0
- package/src/qingflow_mcp/server_app_builder.py +16 -0
- package/src/qingflow_mcp/server_app_user.py +24 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +208 -39
- package/src/qingflow_mcp/tools/app_tools.py +52 -13
- package/src/qingflow_mcp/tools/csv_export.py +168 -0
- package/src/qingflow_mcp/tools/data_tools.py +271 -0
- package/src/qingflow_mcp/tools/login_link_tools.py +142 -0
- package/src/qingflow_mcp/tools/record_tools.py +11 -46
- package/src/qingflow_mcp/tools/resource_read_tools.py +13 -0
|
@@ -8,7 +8,10 @@ from typing import Any, TextIO
|
|
|
8
8
|
def emit_text_result(result: dict[str, Any], *, hint: str, stream: TextIO) -> None:
|
|
9
9
|
text = _format_cancelled_result(result)
|
|
10
10
|
if text is None:
|
|
11
|
-
if
|
|
11
|
+
if hint in _DATA_FORMAT_HINTS:
|
|
12
|
+
# 数据面失败也必须保留 field_errors 与列表型 hints,不能先走通用失败裁剪。
|
|
13
|
+
text = _format_data_result(result)
|
|
14
|
+
elif _is_failed_result(result) and hint != "task_action_execute":
|
|
12
15
|
text = _format_failed_result(result)
|
|
13
16
|
else:
|
|
14
17
|
formatter = _FORMATTERS.get(hint, _format_generic)
|
|
@@ -90,6 +93,92 @@ def _format_generic(result: dict[str, Any]) -> str:
|
|
|
90
93
|
return "\n".join(lines) + "\n"
|
|
91
94
|
|
|
92
95
|
|
|
96
|
+
def _format_data_result(result: dict[str, Any]) -> str:
|
|
97
|
+
lines = [f"Status: {result.get('status') or ('success' if result.get('ok') is not False else 'failed')}"]
|
|
98
|
+
if result.get("format") == "csv":
|
|
99
|
+
files = result.get("files") if isinstance(result.get("files"), list) else []
|
|
100
|
+
lines.extend(
|
|
101
|
+
[
|
|
102
|
+
f"Local dir: {result.get('local_dir') or '-'}",
|
|
103
|
+
f"Files: {len(files)}",
|
|
104
|
+
]
|
|
105
|
+
)
|
|
106
|
+
for item in files:
|
|
107
|
+
if isinstance(item, dict):
|
|
108
|
+
lines.append(
|
|
109
|
+
f"- part {item.get('part')}: {item.get('local_path')} ({item.get('row_count')} rows)"
|
|
110
|
+
)
|
|
111
|
+
lines.extend(
|
|
112
|
+
[
|
|
113
|
+
f"Rows: {result.get('row_count')}",
|
|
114
|
+
f"Complete: {result.get('complete')}",
|
|
115
|
+
f"Truncated: {result.get('truncated')}",
|
|
116
|
+
f"Safe for final conclusion: {result.get('safe_for_final_conclusion')}",
|
|
117
|
+
f"Stopped reason: {result.get('stopped_reason') or '-'}",
|
|
118
|
+
]
|
|
119
|
+
)
|
|
120
|
+
access = result.get("access") if isinstance(result.get("access"), dict) else {}
|
|
121
|
+
if access:
|
|
122
|
+
sources = access.get("scope_sources") if isinstance(access.get("scope_sources"), list) else []
|
|
123
|
+
lines.append(
|
|
124
|
+
f"Access: level={access.get('level') or '-'} / can_write={access.get('can_write')}"
|
|
125
|
+
f" / scope_sources={','.join(str(item) for item in sources) or '-'}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
records = result.get("records") if isinstance(result.get("records"), dict) else {}
|
|
129
|
+
if records:
|
|
130
|
+
items = records.get("items") if isinstance(records.get("items"), list) else []
|
|
131
|
+
lines.append(
|
|
132
|
+
"Pagination: "
|
|
133
|
+
f"page={records.get('page')} / requested_page_size={records.get('requested_page_size')} / "
|
|
134
|
+
f"page_size={records.get('page_size')} / total={records.get('total')} / "
|
|
135
|
+
f"has_more={records.get('has_more')} / returned={len(items)}"
|
|
136
|
+
)
|
|
137
|
+
record = result.get("record") if isinstance(result.get("record"), dict) else {}
|
|
138
|
+
if record:
|
|
139
|
+
lines.append(f"Record ID: {record.get('record_id') or '-'}")
|
|
140
|
+
items = result.get("items") if isinstance(result.get("items"), list) else []
|
|
141
|
+
if items:
|
|
142
|
+
lines.append(f"Items: {len(items)}")
|
|
143
|
+
|
|
144
|
+
for key, label in (
|
|
145
|
+
("error_code", "Error Code"),
|
|
146
|
+
("message", "Message"),
|
|
147
|
+
("write_executed", "Write Executed"),
|
|
148
|
+
("retry_safe", "Retry Safe"),
|
|
149
|
+
):
|
|
150
|
+
if key in result:
|
|
151
|
+
lines.append(f"{label}: {result.get(key)}")
|
|
152
|
+
|
|
153
|
+
field_errors = result.get("field_errors") if isinstance(result.get("field_errors"), list) else []
|
|
154
|
+
if field_errors:
|
|
155
|
+
lines.append("Field Errors:")
|
|
156
|
+
_append_data_field_errors(lines, field_errors)
|
|
157
|
+
for index, item in enumerate(items):
|
|
158
|
+
if not isinstance(item, dict) or not isinstance(item.get("field_errors"), list):
|
|
159
|
+
continue
|
|
160
|
+
lines.append(f"Item {index} Field Errors:")
|
|
161
|
+
_append_data_field_errors(lines, item["field_errors"])
|
|
162
|
+
|
|
163
|
+
hints = result.get("hints") if isinstance(result.get("hints"), list) else []
|
|
164
|
+
lines.append(f"Hints: {len(hints)}")
|
|
165
|
+
for hint in hints:
|
|
166
|
+
if isinstance(hint, dict):
|
|
167
|
+
lines.append(f"- {hint.get('code') or '-'}: {hint.get('message') or '-'}")
|
|
168
|
+
else:
|
|
169
|
+
lines.append(f"- {hint}")
|
|
170
|
+
return "\n".join(lines) + "\n"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _append_data_field_errors(lines: list[str], field_errors: list[Any]) -> None:
|
|
174
|
+
for error in field_errors:
|
|
175
|
+
if isinstance(error, dict):
|
|
176
|
+
lines.append(
|
|
177
|
+
f"- {error.get('path') or '-'}: {error.get('message') or '-'}"
|
|
178
|
+
f" / fix_hint={error.get('fix_hint') or '-'}"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
93
182
|
def _format_whoami(result: dict[str, Any]) -> str:
|
|
94
183
|
lines = [
|
|
95
184
|
f"User: {result.get('nick_name') or '-'} ({result.get('email') or '-'})",
|
|
@@ -191,9 +280,10 @@ def _format_app_items(result: dict[str, Any]) -> str:
|
|
|
191
280
|
str(item.get("app_key") or item.get("appKey") or ""),
|
|
192
281
|
str(item.get("app_name") or item.get("name") or item.get("title") or ""),
|
|
193
282
|
str(item.get("package_name") or item.get("tag_name") or ""),
|
|
283
|
+
str(item.get("uri") or ""),
|
|
194
284
|
]
|
|
195
285
|
)
|
|
196
|
-
return _render_titled_table("Apps", ["app_key", "app_name", "package"], rows)
|
|
286
|
+
return _render_titled_table("Apps", ["app_key", "app_name", "package", "uri"], rows)
|
|
197
287
|
|
|
198
288
|
|
|
199
289
|
def _format_app_get(result: dict[str, Any]) -> str:
|
|
@@ -201,6 +291,7 @@ def _format_app_get(result: dict[str, Any]) -> str:
|
|
|
201
291
|
lines = [
|
|
202
292
|
f"App: {data.get('app_name') or '-'}",
|
|
203
293
|
f"App Key: {data.get('app_key') or '-'}",
|
|
294
|
+
f"URI: {data.get('uri') or '-'}",
|
|
204
295
|
f"Can Create: {data.get('can_create')}",
|
|
205
296
|
]
|
|
206
297
|
import_capability = data.get("import_capability")
|
|
@@ -224,7 +315,7 @@ def _format_app_get(result: dict[str, Any]) -> str:
|
|
|
224
315
|
lines.append(f"Accessible Views: {len(views)}")
|
|
225
316
|
for item in views[:10]:
|
|
226
317
|
if isinstance(item, dict):
|
|
227
|
-
lines.append(f"- {item.get('view_id')}: {item.get('name')}")
|
|
318
|
+
lines.append(f"- {item.get('view_id')}: {item.get('name')} / {item.get('uri') or '-'}")
|
|
228
319
|
_append_warnings(lines, result.get("warnings"))
|
|
229
320
|
return "\n".join(lines) + "\n"
|
|
230
321
|
|
|
@@ -296,6 +387,7 @@ def _format_view_get(result: dict[str, Any]) -> str:
|
|
|
296
387
|
f"View Key: {data.get('view_key') or '-'}",
|
|
297
388
|
f"App Key: {data.get('app_key') or '-'}",
|
|
298
389
|
f"View Type: {data.get('view_type') or '-'}",
|
|
390
|
+
f"URI: {data.get('uri') or '-'}",
|
|
299
391
|
f"Analysis Supported: {data.get('analysis_supported')}",
|
|
300
392
|
]
|
|
301
393
|
if export_capability:
|
|
@@ -1184,6 +1276,7 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
|
|
|
1184
1276
|
("view_key", "View Key"),
|
|
1185
1277
|
("chart_id", "Chart ID"),
|
|
1186
1278
|
("package_id", "Package ID"),
|
|
1279
|
+
("uri", "URI"),
|
|
1187
1280
|
("verified", "Verified"),
|
|
1188
1281
|
("write_executed", "Write Executed"),
|
|
1189
1282
|
("delete_executed", "Delete Executed"),
|
|
@@ -1216,6 +1309,7 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
|
|
|
1216
1309
|
"associations",
|
|
1217
1310
|
"package_ids",
|
|
1218
1311
|
"app_keys",
|
|
1312
|
+
"resources",
|
|
1219
1313
|
):
|
|
1220
1314
|
value = result.get(key)
|
|
1221
1315
|
if not isinstance(value, list) and isinstance(data, dict):
|
|
@@ -1226,6 +1320,15 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
|
|
|
1226
1320
|
lines.append("- " + _builder_item_summary(item))
|
|
1227
1321
|
if len(value) > 8:
|
|
1228
1322
|
lines.append(f"... {len(value) - 8} more")
|
|
1323
|
+
if key == "apps":
|
|
1324
|
+
uri_lines = [
|
|
1325
|
+
line
|
|
1326
|
+
for item in value
|
|
1327
|
+
for line in _builder_batch_item_uri_lines(item)
|
|
1328
|
+
]
|
|
1329
|
+
if uri_lines:
|
|
1330
|
+
lines.append("Resource URIs:")
|
|
1331
|
+
lines.extend(f"- {line}" for line in uri_lines)
|
|
1229
1332
|
components = result.get("components") if isinstance(result.get("components"), list) else []
|
|
1230
1333
|
if components:
|
|
1231
1334
|
lines.append("Component Refs:")
|
|
@@ -1294,6 +1397,7 @@ def _builder_item_summary(item: Any) -> str:
|
|
|
1294
1397
|
"uid",
|
|
1295
1398
|
"userId",
|
|
1296
1399
|
"email",
|
|
1400
|
+
"uri",
|
|
1297
1401
|
)
|
|
1298
1402
|
parts = [
|
|
1299
1403
|
f"{key}={item.get(key)}"
|
|
@@ -1305,6 +1409,50 @@ def _builder_item_summary(item: Any) -> str:
|
|
|
1305
1409
|
return " / ".join(parts) if parts else json.dumps(item, ensure_ascii=False)
|
|
1306
1410
|
|
|
1307
1411
|
|
|
1412
|
+
def _builder_batch_item_uri_lines(item: Any) -> list[str]:
|
|
1413
|
+
if not isinstance(item, dict):
|
|
1414
|
+
return []
|
|
1415
|
+
item_status = str(item.get("status") or "").lower()
|
|
1416
|
+
if item_status not in {"success", "partial_success"}:
|
|
1417
|
+
return []
|
|
1418
|
+
data = item.get("data") if isinstance(item.get("data"), dict) else item.get("result")
|
|
1419
|
+
if not isinstance(data, dict) or not _builder_uri_is_confirmed(data, fallback_status=item_status):
|
|
1420
|
+
return []
|
|
1421
|
+
lines: list[str] = []
|
|
1422
|
+
if data.get("uri"):
|
|
1423
|
+
lines.append(f"URI: {data['uri']}")
|
|
1424
|
+
views = data.get("views") if isinstance(data.get("views"), list) else []
|
|
1425
|
+
for view in views:
|
|
1426
|
+
if (
|
|
1427
|
+
isinstance(view, dict)
|
|
1428
|
+
and view.get("uri")
|
|
1429
|
+
and _builder_uri_is_confirmed(view, fallback_status=item_status)
|
|
1430
|
+
):
|
|
1431
|
+
lines.append(f"View URI: {view['uri']}")
|
|
1432
|
+
return lines
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
def _builder_uri_is_confirmed(value: dict[str, Any], *, fallback_status: str) -> bool:
|
|
1436
|
+
status = str(value.get("status") or fallback_status).lower()
|
|
1437
|
+
operation = str(value.get("operation") or "").lower()
|
|
1438
|
+
return (
|
|
1439
|
+
status in {"success", "partial_success", "unchanged", "verified"}
|
|
1440
|
+
and operation
|
|
1441
|
+
not in {
|
|
1442
|
+
"failed",
|
|
1443
|
+
"pending",
|
|
1444
|
+
"planned",
|
|
1445
|
+
"readback_pending",
|
|
1446
|
+
"pending_readback",
|
|
1447
|
+
"unknown",
|
|
1448
|
+
"remove",
|
|
1449
|
+
"removed",
|
|
1450
|
+
"delete",
|
|
1451
|
+
"deleted",
|
|
1452
|
+
}
|
|
1453
|
+
)
|
|
1454
|
+
|
|
1455
|
+
|
|
1308
1456
|
def _format_file_upload_local(result: dict[str, Any]) -> str:
|
|
1309
1457
|
lines = [
|
|
1310
1458
|
f"Upload Kind: {result.get('upload_kind') or result.get('requested_upload_kind') or '-'}",
|
|
@@ -1566,7 +1714,12 @@ def _task_action_debug_lines(result: dict[str, Any]) -> list[str]:
|
|
|
1566
1714
|
return lines
|
|
1567
1715
|
|
|
1568
1716
|
|
|
1717
|
+
def _format_login_link(result: dict[str, Any]) -> str:
|
|
1718
|
+
return f"登录链接:{result['url']}\n过期时间(Unix 毫秒):{result['expires_at']}\n链接只能使用一次,请勿泄露;仅在用户要求打开时才打开。\n"
|
|
1719
|
+
|
|
1720
|
+
|
|
1569
1721
|
_FORMATTERS = {
|
|
1722
|
+
"login_link": _format_login_link,
|
|
1570
1723
|
"auth_whoami": _format_whoami,
|
|
1571
1724
|
"workspace_list": _format_workspace_list,
|
|
1572
1725
|
"workspace_get": _format_workspace_get,
|
|
@@ -1604,4 +1757,11 @@ _FORMATTERS = {
|
|
|
1604
1757
|
"builder_summary": _format_builder_summary,
|
|
1605
1758
|
"file_upload_local": _format_file_upload_local,
|
|
1606
1759
|
"feedback_submit": _format_feedback_submit,
|
|
1760
|
+
"data_query": _format_data_result,
|
|
1761
|
+
"data_get": _format_data_result,
|
|
1762
|
+
"data_insert": _format_data_result,
|
|
1763
|
+
"data_update": _format_data_result,
|
|
1764
|
+
"data_delete": _format_data_result,
|
|
1607
1765
|
}
|
|
1766
|
+
|
|
1767
|
+
_DATA_FORMAT_HINTS = {"data_query", "data_get", "data_insert", "data_update", "data_delete"}
|
|
@@ -2,11 +2,13 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import json
|
|
5
|
+
import os
|
|
5
6
|
import sys
|
|
6
7
|
from typing import Any, Callable, TextIO
|
|
7
8
|
|
|
8
9
|
from ..errors import QingflowApiError, backend_code_value_int, message_looks_like_invalid_token
|
|
9
10
|
from ..public_surface import cli_public_tool_spec_from_namespace
|
|
11
|
+
from ..resource_uri import cli_resource_base_url
|
|
10
12
|
from ..response_trim import resolve_cli_tool_name, trim_error_response, trim_public_response
|
|
11
13
|
from ..tools.ai_builder_tools import _attach_builder_apply_envelope
|
|
12
14
|
from ..version import get_cli_version, get_cli_version_info
|
|
@@ -94,7 +96,8 @@ def run(
|
|
|
94
96
|
context = context_factory()
|
|
95
97
|
if not bool(args.json):
|
|
96
98
|
_emit_cli_effective_context_notice(args, context, stream=err)
|
|
97
|
-
|
|
99
|
+
with cli_resource_base_url(os.getenv("QINGFLOW_BASE_URL") or ""):
|
|
100
|
+
result = handler(args, context)
|
|
98
101
|
except SystemExit as exc:
|
|
99
102
|
return int(exc.code or 0)
|
|
100
103
|
except RuntimeError as exc:
|
|
@@ -2,16 +2,20 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import re
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Any
|
|
7
8
|
from urllib.parse import urlsplit, urlunsplit
|
|
8
9
|
|
|
10
|
+
from .errors import QingflowApiError
|
|
11
|
+
|
|
9
12
|
DEFAULT_PROFILE = "default"
|
|
10
13
|
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
11
14
|
DEFAULT_USER_AGENT = "qingflow-mcp/1.0"
|
|
12
15
|
DEFAULT_RECORD_LIST_TYPE = 8
|
|
13
16
|
ATTACHMENT_QUESTION_TYPE = 13
|
|
14
17
|
DEFAULT_BASE_URL = "https://qingflow.com/api"
|
|
18
|
+
DEFAULT_WEB_BASE_URL = "https://qingflow.com"
|
|
15
19
|
DEFAULT_CLI_OAUTH_RESOURCE_URL = "https://mcp.qingflow.com/mcp"
|
|
16
20
|
DEFAULT_FEEDBACK_APP_KEY = "e0d017kju002"
|
|
17
21
|
DEFAULT_FEEDBACK_QSOURCE_TOKEN = "mcp-feedback-7755d14748fc"
|
|
@@ -153,6 +157,41 @@ def get_default_base_url() -> str | None:
|
|
|
153
157
|
return normalize_base_url(value) if value else None
|
|
154
158
|
|
|
155
159
|
|
|
160
|
+
def get_web_base_url() -> str:
|
|
161
|
+
"""读取网页根地址;空白配置逐级回退,不复用后端或 OAuth 地址。"""
|
|
162
|
+
value = os.getenv("QINGFLOW_MCP_WEB_BASE_URL")
|
|
163
|
+
if value is None or not value.strip():
|
|
164
|
+
value = get_config_value("web_base_url", default=DEFAULT_WEB_BASE_URL)
|
|
165
|
+
if isinstance(value, str) and not value.strip():
|
|
166
|
+
value = DEFAULT_WEB_BASE_URL
|
|
167
|
+
try:
|
|
168
|
+
if not isinstance(value, str):
|
|
169
|
+
raise ValueError
|
|
170
|
+
value = value.strip()
|
|
171
|
+
parsed = urlsplit(value)
|
|
172
|
+
host = parsed.hostname or ""
|
|
173
|
+
# urlsplit 会校验 IPv6;域名标签额外拒绝空格、转义字符及无效边界。
|
|
174
|
+
valid_host = bool(host) and (":" in host or all(
|
|
175
|
+
re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label)
|
|
176
|
+
for label in host.rstrip(".").split(".")
|
|
177
|
+
))
|
|
178
|
+
if (
|
|
179
|
+
parsed.scheme not in {"http", "https"}
|
|
180
|
+
or not valid_host or host.lower() in {"null", "undefined"}
|
|
181
|
+
or parsed.username is not None or parsed.password is not None
|
|
182
|
+
or parsed.path not in {"", "/"} or "?" in value or "#" in value
|
|
183
|
+
or any(character.isspace() or ord(character) < 32 for character in value)
|
|
184
|
+
):
|
|
185
|
+
raise ValueError
|
|
186
|
+
# 访问 port 触发 urllib 的端口格式与范围校验。
|
|
187
|
+
_ = parsed.port
|
|
188
|
+
return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
|
|
189
|
+
except ValueError:
|
|
190
|
+
raise QingflowApiError.config_error(
|
|
191
|
+
"QINGFLOW_MCP_WEB_BASE_URL(web_base_url)必须是有效的 HTTP(S) 网页根地址,不包含路径、查询参数或用户凭证"
|
|
192
|
+
) from None
|
|
193
|
+
|
|
194
|
+
|
|
156
195
|
def get_default_qf_version() -> str | None:
|
|
157
196
|
"""获取默认的 qfVersion 路由值"""
|
|
158
197
|
value = get_config_value(
|
|
@@ -33,12 +33,18 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
33
33
|
PublicToolSpec(USER_DOMAIN, "auth_login", cli_route=("auth", "login"), mcp_public=False),
|
|
34
34
|
PublicToolSpec(USER_DOMAIN, "auth_use_credential", ("auth_use_credential",), ("auth", "use-credential")),
|
|
35
35
|
PublicToolSpec(USER_DOMAIN, "auth_whoami", ("auth_whoami",), ("auth", "whoami")),
|
|
36
|
+
PublicToolSpec(USER_DOMAIN, "get_login_link", ("get_login_link",), ("auth", "login-link")),
|
|
36
37
|
PublicToolSpec(USER_DOMAIN, "auth_logout", ("auth_logout",), ("auth", "logout")),
|
|
37
38
|
PublicToolSpec(USER_DOMAIN, "workspace_list", ("workspace_list",), ("workspace", "list")),
|
|
38
39
|
PublicToolSpec(USER_DOMAIN, "workspace_get", ("workspace_get",), ("workspace", "get")),
|
|
39
40
|
PublicToolSpec(USER_DOMAIN, "workspace_select", ("workspace_select",), ("workspace", "select")),
|
|
40
41
|
PublicToolSpec(USER_DOMAIN, "app_list", ("app_list",), ("app", "list"), cli_show_effective_context=True),
|
|
41
42
|
PublicToolSpec(USER_DOMAIN, "app_get", ("app_get",), ("app", "get"), cli_show_effective_context=True),
|
|
43
|
+
PublicToolSpec(USER_DOMAIN, "data_query", ("data_query",), ("data", "query"), cli_show_effective_context=True),
|
|
44
|
+
PublicToolSpec(USER_DOMAIN, "data_get", ("data_get",), ("data", "get"), cli_show_effective_context=True),
|
|
45
|
+
PublicToolSpec(USER_DOMAIN, "data_insert", ("data_insert",), ("data", "insert"), cli_show_effective_context=True, cli_context_write=True),
|
|
46
|
+
PublicToolSpec(USER_DOMAIN, "data_update", ("data_update",), ("data", "update"), cli_show_effective_context=True, cli_context_write=True),
|
|
47
|
+
PublicToolSpec(USER_DOMAIN, "data_delete", ("data_delete",), ("data", "delete"), cli_show_effective_context=True, cli_context_write=True),
|
|
42
48
|
PublicToolSpec(USER_DOMAIN, "portal_list", ("portal_list",), ("portal", "list"), cli_show_effective_context=True),
|
|
43
49
|
PublicToolSpec(USER_DOMAIN, "portal_get", ("portal_get",), ("portal", "get"), cli_show_effective_context=True),
|
|
44
50
|
PublicToolSpec(USER_DOMAIN, "view_get", ("view_get",), ("view", "get"), cli_show_effective_context=True),
|
|
@@ -127,6 +133,7 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
127
133
|
PublicToolSpec(BUILDER_DOMAIN, "auth_use_credential", ("auth_use_credential",), ("builder", "auth", "use-credential"), cli_public=False),
|
|
128
134
|
PublicToolSpec(BUILDER_DOMAIN, "auth_whoami", ("auth_whoami",), ("builder", "auth", "whoami"), cli_public=False),
|
|
129
135
|
PublicToolSpec(BUILDER_DOMAIN, "auth_logout", ("auth_logout",), ("builder", "auth", "logout"), cli_public=False),
|
|
136
|
+
PublicToolSpec(BUILDER_DOMAIN, "get_login_link", ("get_login_link",), mcp_public=True, cli_public=False),
|
|
130
137
|
PublicToolSpec(BUILDER_DOMAIN, "workspace_list", ("workspace_list",), ("builder", "workspace", "list"), cli_public=False),
|
|
131
138
|
PublicToolSpec(BUILDER_DOMAIN, "workspace_get", ("workspace_get",), ("builder", "workspace", "get"), cli_public=False),
|
|
132
139
|
PublicToolSpec(BUILDER_DOMAIN, "file_upload_local", ("file_upload_local",), ("builder", "file", "upload-local"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
|
10
|
+
|
|
11
|
+
from .config import DEFAULT_WEB_BASE_URL, get_web_base_url
|
|
12
|
+
from .errors import QingflowApiError
|
|
13
|
+
from .json_types import JSONObject
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SYSTEM_VIEW_NAMES = frozenset({"all", "initiated", "todo", "done", "cc"})
|
|
17
|
+
_ENCODED_OCTET = re.compile(r"%[0-9A-Fa-f]{2}")
|
|
18
|
+
_CLI_RESOURCE_BASE_URL: ContextVar[str | None] = ContextVar(
|
|
19
|
+
"qingflow_cli_resource_base_url",
|
|
20
|
+
default=None,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class ResourcePath:
|
|
26
|
+
resource_type: str
|
|
27
|
+
ws_id: int
|
|
28
|
+
package_id: int | None = None
|
|
29
|
+
app_key: str | None = None
|
|
30
|
+
view_kind: str | None = None
|
|
31
|
+
view_key: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@contextmanager
|
|
35
|
+
def cli_resource_base_url(value: str) -> Iterator[None]:
|
|
36
|
+
"""让一次 CLI 调用的资源 URI 使用 QINGFLOW_BASE_URL 对应的网页环境。"""
|
|
37
|
+
token = _CLI_RESOURCE_BASE_URL.set(value)
|
|
38
|
+
try:
|
|
39
|
+
yield
|
|
40
|
+
finally:
|
|
41
|
+
_CLI_RESOURCE_BASE_URL.reset(token)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_resource_identity(value: object, field: str = "resource_id") -> str:
|
|
45
|
+
"""按资源 URI 单路径段合同校验并返回规范身份,不读取网页域名配置。"""
|
|
46
|
+
return _validate_identity(value, field)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate_positive_resource_id(value: object, field: str = "resource_id") -> int:
|
|
50
|
+
"""按资源 URI 数字路径段合同校验正整数,不接受布尔值或浮点截断。"""
|
|
51
|
+
return int(_positive_int_segment(value, field))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_resource_uri(
|
|
55
|
+
*,
|
|
56
|
+
ws_id: object,
|
|
57
|
+
resource_type: str,
|
|
58
|
+
package_id: object = None,
|
|
59
|
+
app_key: object = None,
|
|
60
|
+
view_kind: str | None = None,
|
|
61
|
+
view_key: object = None,
|
|
62
|
+
web_origin: str | None = None,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""按固定公共合同构造资源 URI,不接受通用目标路由。"""
|
|
65
|
+
workspace = _positive_int_segment(ws_id, "ws_id")
|
|
66
|
+
if resource_type == "package":
|
|
67
|
+
path = f"/resources/ws/{workspace}/package/{_positive_int_segment(package_id, 'package_id')}"
|
|
68
|
+
elif resource_type == "app":
|
|
69
|
+
path = f"/resources/ws/{workspace}/app/{_identity_segment(app_key, 'app_key')}"
|
|
70
|
+
elif resource_type == "view":
|
|
71
|
+
app_segment = _identity_segment(app_key, "app_key")
|
|
72
|
+
if view_kind == "custom":
|
|
73
|
+
view_segment = _identity_segment(view_key, "view_key")
|
|
74
|
+
elif view_kind == "system":
|
|
75
|
+
name = str(view_key or "").strip()
|
|
76
|
+
if name not in SYSTEM_VIEW_NAMES:
|
|
77
|
+
raise ValueError("system view name is not supported")
|
|
78
|
+
view_segment = name
|
|
79
|
+
else:
|
|
80
|
+
raise ValueError("view_kind must be custom or system")
|
|
81
|
+
path = f"/resources/ws/{workspace}/app/{app_segment}/view/{view_kind}/{view_segment}"
|
|
82
|
+
else:
|
|
83
|
+
raise ValueError("resource_type is not supported")
|
|
84
|
+
return f"{resolve_resource_web_origin(web_origin)}{path}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_resource_path(value: str) -> ResourcePath:
|
|
88
|
+
"""严格解析资源 pathname,供免登录签发前校验工作区。"""
|
|
89
|
+
try:
|
|
90
|
+
parsed = urlsplit(value)
|
|
91
|
+
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment or parsed.path != value:
|
|
92
|
+
raise ValueError
|
|
93
|
+
parts = parsed.path.split("/")
|
|
94
|
+
if len(parts) < 6 or parts[:3] != ["", "resources", "ws"]:
|
|
95
|
+
raise ValueError
|
|
96
|
+
ws_id = int(_decode_segment(parts[3], "ws_id"))
|
|
97
|
+
if ws_id <= 0:
|
|
98
|
+
raise ValueError
|
|
99
|
+
if len(parts) == 6 and parts[4] == "package":
|
|
100
|
+
package_id = int(_decode_segment(parts[5], "package_id"))
|
|
101
|
+
if package_id <= 0:
|
|
102
|
+
raise ValueError
|
|
103
|
+
return ResourcePath("package", ws_id, package_id=package_id)
|
|
104
|
+
if len(parts) == 6 and parts[4] == "app":
|
|
105
|
+
return ResourcePath("app", ws_id, app_key=_decode_segment(parts[5], "app_key"))
|
|
106
|
+
if len(parts) == 9 and parts[4] == "app" and parts[6] == "view":
|
|
107
|
+
app_key = _decode_segment(parts[5], "app_key")
|
|
108
|
+
view_kind = parts[7]
|
|
109
|
+
view_key = _decode_segment(parts[8], "view_key")
|
|
110
|
+
if view_kind == "system" and view_key not in SYSTEM_VIEW_NAMES:
|
|
111
|
+
raise ValueError
|
|
112
|
+
if view_kind not in {"custom", "system"}:
|
|
113
|
+
raise ValueError
|
|
114
|
+
return ResourcePath("view", ws_id, app_key=app_key, view_kind=view_kind, view_key=view_key)
|
|
115
|
+
except (TypeError, ValueError, UnicodeError):
|
|
116
|
+
pass
|
|
117
|
+
raise ValueError("resource path is invalid")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def try_build_resource_uri(**kwargs: Any) -> tuple[str | None, JSONObject | None]:
|
|
121
|
+
try:
|
|
122
|
+
return build_resource_uri(**kwargs), None
|
|
123
|
+
except (QingflowApiError, ValueError, UnicodeError) as error:
|
|
124
|
+
return None, {
|
|
125
|
+
"code": "RESOURCE_URI_UNAVAILABLE",
|
|
126
|
+
"message": f"resource URI was omitted because its identity or web origin is invalid: {error}",
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _positive_int_segment(value: object, field: str) -> str:
|
|
131
|
+
if isinstance(value, bool):
|
|
132
|
+
raise ValueError(f"{field} must be a positive integer")
|
|
133
|
+
try:
|
|
134
|
+
parsed = int(value)
|
|
135
|
+
except (TypeError, ValueError):
|
|
136
|
+
raise ValueError(f"{field} must be a positive integer") from None
|
|
137
|
+
if parsed <= 0 or str(value).strip() != str(parsed):
|
|
138
|
+
raise ValueError(f"{field} must be a positive integer")
|
|
139
|
+
return str(parsed)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _normalize_web_origin(value: str) -> str:
|
|
143
|
+
try:
|
|
144
|
+
parsed = urlsplit(value.strip())
|
|
145
|
+
if (
|
|
146
|
+
parsed.scheme not in {"http", "https"}
|
|
147
|
+
or not parsed.hostname
|
|
148
|
+
or parsed.username is not None
|
|
149
|
+
or parsed.password is not None
|
|
150
|
+
or parsed.path not in {"", "/"}
|
|
151
|
+
or parsed.query
|
|
152
|
+
or parsed.fragment
|
|
153
|
+
or any(character.isspace() or ord(character) < 32 for character in value)
|
|
154
|
+
):
|
|
155
|
+
raise ValueError
|
|
156
|
+
_ = parsed.port
|
|
157
|
+
except (AttributeError, ValueError):
|
|
158
|
+
raise ValueError("web_origin must be an absolute HTTP(S) origin") from None
|
|
159
|
+
return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def resolve_resource_web_origin(web_origin: str | None = None) -> str:
|
|
163
|
+
"""解析当前调用面的网页根地址;CLI 与 MCP 分别沿用各自的环境配置。"""
|
|
164
|
+
if web_origin is not None:
|
|
165
|
+
return _normalize_web_origin(web_origin)
|
|
166
|
+
cli_base_url = _CLI_RESOURCE_BASE_URL.get()
|
|
167
|
+
if cli_base_url is None:
|
|
168
|
+
return get_web_base_url()
|
|
169
|
+
return _web_origin_from_api_base_url(cli_base_url)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _web_origin_from_api_base_url(value: str) -> str:
|
|
173
|
+
normalized = value.strip()
|
|
174
|
+
if not normalized:
|
|
175
|
+
return DEFAULT_WEB_BASE_URL
|
|
176
|
+
try:
|
|
177
|
+
parsed = urlsplit(normalized)
|
|
178
|
+
host = parsed.hostname or ""
|
|
179
|
+
valid_host = bool(host) and (":" in host or all(
|
|
180
|
+
re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label)
|
|
181
|
+
for label in host.rstrip(".").split(".")
|
|
182
|
+
))
|
|
183
|
+
if (
|
|
184
|
+
parsed.scheme not in {"http", "https"}
|
|
185
|
+
or not valid_host
|
|
186
|
+
or host.lower() in {"null", "undefined"}
|
|
187
|
+
or parsed.username is not None
|
|
188
|
+
or parsed.password is not None
|
|
189
|
+
or parsed.query
|
|
190
|
+
or parsed.fragment
|
|
191
|
+
or any(character.isspace() or ord(character) < 32 for character in normalized)
|
|
192
|
+
):
|
|
193
|
+
raise ValueError
|
|
194
|
+
_ = parsed.port
|
|
195
|
+
return _normalize_web_origin(urlunsplit((parsed.scheme, parsed.netloc, "", "", "")))
|
|
196
|
+
except ValueError:
|
|
197
|
+
raise QingflowApiError.config_error(
|
|
198
|
+
"QINGFLOW_BASE_URL 必须是有效的 HTTP(S) API 地址,不包含查询参数、片段或用户凭证"
|
|
199
|
+
) from None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _identity_segment(value: object, field: str) -> str:
|
|
203
|
+
raw = _validate_identity(value, field)
|
|
204
|
+
return quote(raw, safe="")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _decode_segment(value: str, field: str) -> str:
|
|
208
|
+
decoded = _validate_identity(unquote(value, errors="strict"), field)
|
|
209
|
+
if quote(decoded, safe="") != value:
|
|
210
|
+
raise ValueError(f"{field} must use canonical path encoding")
|
|
211
|
+
return decoded
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _validate_identity(value: object, field: str) -> str:
|
|
215
|
+
source = str(value or "")
|
|
216
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in source):
|
|
217
|
+
raise ValueError(f"{field} must be one safe path segment")
|
|
218
|
+
raw = source.strip()
|
|
219
|
+
if not raw:
|
|
220
|
+
raise ValueError(f"{field} is required")
|
|
221
|
+
current = raw
|
|
222
|
+
for _ in range(8):
|
|
223
|
+
if (
|
|
224
|
+
current in {".", ".."}
|
|
225
|
+
or any(character in current for character in "/\\?#")
|
|
226
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in current)
|
|
227
|
+
):
|
|
228
|
+
raise ValueError(f"{field} must be one safe path segment")
|
|
229
|
+
decoded = unquote(current, errors="strict")
|
|
230
|
+
if decoded == current:
|
|
231
|
+
return raw
|
|
232
|
+
current = decoded
|
|
233
|
+
if (
|
|
234
|
+
current in {".", ".."}
|
|
235
|
+
or any(character in current for character in "/\\?#")
|
|
236
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in current)
|
|
237
|
+
):
|
|
238
|
+
raise ValueError(f"{field} must be one safe path segment")
|
|
239
|
+
if _ENCODED_OCTET.search(current):
|
|
240
|
+
raise ValueError(f"{field} has excessive encoding")
|
|
241
|
+
return raw
|
|
@@ -326,7 +326,7 @@ def _trim_workspace_get(payload: JSONObject) -> None:
|
|
|
326
326
|
|
|
327
327
|
def _trim_app_list_like(payload: JSONObject) -> None:
|
|
328
328
|
payload.pop("apps", None)
|
|
329
|
-
_trim_item_list(payload, "items", allowed=("app_key", "app_name", "package_name"))
|
|
329
|
+
_trim_item_list(payload, "items", allowed=("app_key", "app_name", "package_name", "uri"))
|
|
330
330
|
|
|
331
331
|
|
|
332
332
|
def _trim_app_get(payload: JSONObject) -> None:
|
|
@@ -334,7 +334,7 @@ def _trim_app_get(payload: JSONObject) -> None:
|
|
|
334
334
|
_trim_nested_item_list(
|
|
335
335
|
payload,
|
|
336
336
|
("data", "accessible_views"),
|
|
337
|
-
allowed=("view_id", "name", "analysis_supported", "list_supported", "kind"),
|
|
337
|
+
allowed=("view_id", "name", "analysis_supported", "list_supported", "kind", "uri"),
|
|
338
338
|
)
|
|
339
339
|
|
|
340
340
|
|
|
@@ -1134,6 +1134,13 @@ def _summarize_form_diff(diff: dict[str, Any]) -> dict[str, Any]:
|
|
|
1134
1134
|
return summary
|
|
1135
1135
|
|
|
1136
1136
|
|
|
1137
|
+
def _trim_login_link(payload: JSONObject) -> None:
|
|
1138
|
+
# 临时票据只通过链接交付,必须保留链接和过期时间。
|
|
1139
|
+
for key in list(payload):
|
|
1140
|
+
if key not in {"url", "expires_at"}:
|
|
1141
|
+
payload.pop(key)
|
|
1142
|
+
|
|
1143
|
+
|
|
1137
1144
|
def _register_policy(domains: tuple[str, ...], names: tuple[str, ...], transform: TransformFn) -> None:
|
|
1138
1145
|
for domain in domains:
|
|
1139
1146
|
for name in names:
|
|
@@ -1215,6 +1222,7 @@ _register_policy(
|
|
|
1215
1222
|
_trim_directory,
|
|
1216
1223
|
)
|
|
1217
1224
|
_register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("feedback_submit",), _trim_feedback)
|
|
1225
|
+
_register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("get_login_link",), _trim_login_link)
|
|
1218
1226
|
_register_policy(
|
|
1219
1227
|
(USER_DOMAIN,),
|
|
1220
1228
|
(
|