@qingflow-tech/qingflow-cli 1.1.46 → 1.1.47-beta.2
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/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/SKILL.md +26 -2
- package/skills/qingflow-common/SKILL.md +21 -1
- package/src/qingflow_mcp/app_form_validator.py +304 -0
- package/src/qingflow_mcp/builder_facade/service.py +124 -1
- package/src/qingflow_mcp/cli/commands/auth.py +8 -0
- package/src/qingflow_mcp/cli/commands/builder.py +327 -1
- package/src/qingflow_mcp/cli/context.py +3 -0
- package/src/qingflow_mcp/cli/formatters.py +66 -2
- package/src/qingflow_mcp/config.py +39 -0
- package/src/qingflow_mcp/public_surface.py +4 -0
- package/src/qingflow_mcp/resource_uri.py +184 -0
- package/src/qingflow_mcp/response_trim.py +16 -2
- package/src/qingflow_mcp/server.py +2 -0
- package/src/qingflow_mcp/server_app_builder.py +7 -0
- package/src/qingflow_mcp/server_app_user.py +18 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +266 -39
- package/src/qingflow_mcp/tools/app_tools.py +52 -13
- package/src/qingflow_mcp/tools/login_link_tools.py +134 -0
- package/src/qingflow_mcp/tools/resource_read_tools.py +13 -0
|
@@ -2,9 +2,23 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
from copy import deepcopy
|
|
5
|
+
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
from ..context import CliContext
|
|
7
|
-
from ...app_form_validator import
|
|
8
|
+
from ...app_form_validator import (
|
|
9
|
+
AppFormValidationError,
|
|
10
|
+
build_encoded_id_map,
|
|
11
|
+
referenced_formula_field_ids,
|
|
12
|
+
translate_formula_test_case_values,
|
|
13
|
+
encode_code_block_field_ids,
|
|
14
|
+
referenced_code_block_field_ids,
|
|
15
|
+
restore_decimal_ids_in_text,
|
|
16
|
+
strip_placeholder_quotes,
|
|
17
|
+
translate_select_test_case_values,
|
|
18
|
+
validate_code_block_static,
|
|
19
|
+
validate_declaration,
|
|
20
|
+
validate_formula_expression,
|
|
21
|
+
)
|
|
8
22
|
from ..json_io import load_json_value
|
|
9
23
|
from .common import load_list_arg, load_object_arg, parse_bool_text, raise_config_error, require_list_arg
|
|
10
24
|
|
|
@@ -128,6 +142,41 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
128
142
|
app_form_validate.add_argument("--schema-version", help="固定使用的 AppForm Schema 版本")
|
|
129
143
|
app_form_validate.set_defaults(handler=_handle_app_form_validate, format_hint="builder_summary", force_json_output=True)
|
|
130
144
|
|
|
145
|
+
app_form_validate_formula = app_form_subparsers.add_parser(
|
|
146
|
+
"validate-formula", help="校验公式表达式(本地语法 + 后端求值)"
|
|
147
|
+
)
|
|
148
|
+
app_form_validate_formula.add_argument("--app-key", required=True, help="目标应用 key")
|
|
149
|
+
app_form_validate_formula.add_argument("--formula", required=True, help="公式文本")
|
|
150
|
+
app_form_validate_formula.add_argument(
|
|
151
|
+
"--test-case",
|
|
152
|
+
required=True,
|
|
153
|
+
help="测试用例 JSON 文件路径,- 表示 stdin;格式 {queId: value}",
|
|
154
|
+
)
|
|
155
|
+
app_form_validate_formula.add_argument(
|
|
156
|
+
"--fields",
|
|
157
|
+
required=False,
|
|
158
|
+
help="字段清单 JSON 文件路径(用于本地字段引用校验);缺省时从后端拉取",
|
|
159
|
+
)
|
|
160
|
+
app_form_validate_formula.set_defaults(
|
|
161
|
+
handler=_handle_app_form_validate_formula, format_hint="builder_summary", force_json_output=True
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
app_form_validate_code_block = app_form_subparsers.add_parser(
|
|
165
|
+
"validate-code-block", help="校验代码块(本地静态 + 后端真跑)"
|
|
166
|
+
)
|
|
167
|
+
app_form_validate_code_block.add_argument("--app-key", required=True, help="目标应用 key")
|
|
168
|
+
app_form_validate_code_block.add_argument(
|
|
169
|
+
"--code", required=True, help="代码文本(或 @文件路径读取)"
|
|
170
|
+
)
|
|
171
|
+
app_form_validate_code_block.add_argument(
|
|
172
|
+
"--test-case",
|
|
173
|
+
required=True,
|
|
174
|
+
help="测试用例 JSON 文件路径,- 表示 stdin;格式 [{keyQueId, ordinal?, values}]",
|
|
175
|
+
)
|
|
176
|
+
app_form_validate_code_block.set_defaults(
|
|
177
|
+
handler=_handle_app_form_validate_code_block, format_hint="builder_summary", force_json_output=True
|
|
178
|
+
)
|
|
179
|
+
|
|
131
180
|
app = builder_subparsers.add_parser("app", help="应用")
|
|
132
181
|
app_subparsers = app.add_subparsers(dest="builder_app_command", required=True)
|
|
133
182
|
|
|
@@ -395,6 +444,283 @@ def _handle_app_form_validate(args: argparse.Namespace, _context: CliContext) ->
|
|
|
395
444
|
return {"category": "validation", "status": "success", "ok": True, **result, "message": "AppForm declaration is valid"}
|
|
396
445
|
|
|
397
446
|
|
|
447
|
+
def _handle_app_form_validate_formula(args: argparse.Namespace, context: CliContext) -> dict:
|
|
448
|
+
formula = args.formula
|
|
449
|
+
test_case = load_json_value(
|
|
450
|
+
args.test_case, option_name="--test-case", max_bytes=1 * 1024 * 1024, max_depth=32
|
|
451
|
+
)
|
|
452
|
+
if not isinstance(test_case, dict):
|
|
453
|
+
raise_config_error(
|
|
454
|
+
"--test-case must be a JSON object {queId: value}", error_code="INVALID_TEST_CASE"
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# 字段定义:优先 --fields;缺省拉全量(保留 option_details 供选择类值翻译)
|
|
458
|
+
fields_path = getattr(args, "fields", None)
|
|
459
|
+
if fields_path:
|
|
460
|
+
fields = _load_fields_for_validation(args, context)
|
|
461
|
+
else:
|
|
462
|
+
fields = _load_code_block_validation_fields(args, context) or _load_fields_for_validation(args, context)
|
|
463
|
+
|
|
464
|
+
# ① 本地语法校验(函数名 + 字段引用存在性)
|
|
465
|
+
syntax_fields = [
|
|
466
|
+
{"queId": f.get("que_id") or f.get("queId"), "name": f.get("name")} for f in (fields or [])
|
|
467
|
+
] if fields else None
|
|
468
|
+
syntax_errors = validate_formula_expression(formula, syntax_fields)
|
|
469
|
+
|
|
470
|
+
# ①' 选择类值翻译:实测后端只认 [optId 数组],文案/标量 optId 均报错——把文案自动翻成 [optId]
|
|
471
|
+
test_case, select_changes, option_errors = translate_formula_test_case_values(fields, test_case)
|
|
472
|
+
|
|
473
|
+
# ①'' 覆盖校验:公式引用缺值字段会静默变空(CONCAT 直接跳过)→ 空洞通过,这里拦下
|
|
474
|
+
referenced_ids = referenced_formula_field_ids(formula)
|
|
475
|
+
underivable = _underivable_field_ids(fields)
|
|
476
|
+
covered_ids = {int(k) for k in test_case if str(k).isdigit()}
|
|
477
|
+
missing_ids = sorted((referenced_ids - covered_ids) - underivable)
|
|
478
|
+
coverage_errors = [
|
|
479
|
+
{
|
|
480
|
+
"pointer": "test-case",
|
|
481
|
+
"message": f"field queId {que_id} is referenced by formula but has no test-case value "
|
|
482
|
+
f"(reference would evaluate as empty)",
|
|
483
|
+
}
|
|
484
|
+
for que_id in missing_ids
|
|
485
|
+
]
|
|
486
|
+
|
|
487
|
+
# ② 后端求值校验(本地有错时不浪费后端调用)
|
|
488
|
+
evaluation = None
|
|
489
|
+
evaluation_error = None
|
|
490
|
+
if not syntax_errors and not coverage_errors and not option_errors:
|
|
491
|
+
result = context.builder.app_form_evaluate_formula(
|
|
492
|
+
profile=args.profile, app_key=args.app_key, formula=formula, test_case=test_case
|
|
493
|
+
)
|
|
494
|
+
evaluation = result.get("evaluation")
|
|
495
|
+
evaluation_error = result.get("evaluationError")
|
|
496
|
+
|
|
497
|
+
ok = bool(
|
|
498
|
+
not syntax_errors
|
|
499
|
+
and not coverage_errors
|
|
500
|
+
and not option_errors
|
|
501
|
+
and isinstance(evaluation, dict)
|
|
502
|
+
and evaluation.get("valid") is True
|
|
503
|
+
)
|
|
504
|
+
return {
|
|
505
|
+
"category": "validation",
|
|
506
|
+
"status": "success" if ok else "failed",
|
|
507
|
+
"ok": ok,
|
|
508
|
+
"syntaxErrors": syntax_errors,
|
|
509
|
+
"coverageErrors": coverage_errors,
|
|
510
|
+
"optionErrors": option_errors,
|
|
511
|
+
"autoFixes": {"selectValuesTranslated": select_changes},
|
|
512
|
+
"evaluation": evaluation,
|
|
513
|
+
"evaluationError": evaluation_error,
|
|
514
|
+
"message": "formula is valid" if ok else "formula validation failed",
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _handle_app_form_validate_code_block(args: argparse.Namespace, context: CliContext) -> dict:
|
|
519
|
+
raw_code = _read_code_arg(args.code)
|
|
520
|
+
# 占位符容错:模型常给字段名加引号(qf_field.{"名$$id$$"}),后端替换正则要求结尾恰为
|
|
521
|
+
# `$$}`,带引号直接匹配失败→占位符原样进 JS→语法错误。先剥离引号再进编码。
|
|
522
|
+
code_after_quotes, quote_fixes = strip_placeholder_quotes(raw_code)
|
|
523
|
+
# 后端运行时按 encryptQueId 的编码形态解码代码里的 $$id$$ 段再匹配 test-case 填值;
|
|
524
|
+
# AI 生成的代码是十进制 queId,先编码归一化(含字母的已编码段原样保留,防双重编码)。
|
|
525
|
+
code = encode_code_block_field_ids(code_after_quotes)
|
|
526
|
+
# 错误回显还原映射:后端错误里出现的编码 id 还原为模型写的十进制,避免模型逆向混淆。
|
|
527
|
+
encoded_id_map = build_encoded_id_map(code_after_quotes)
|
|
528
|
+
test_case = load_json_value(
|
|
529
|
+
args.test_case, option_name="--test-case", max_bytes=1 * 1024 * 1024, max_depth=32
|
|
530
|
+
)
|
|
531
|
+
if not isinstance(test_case, list):
|
|
532
|
+
raise_config_error(
|
|
533
|
+
"--test-case must be a JSON array [{keyQueId, ordinal?, values}]",
|
|
534
|
+
error_code="INVALID_TEST_CASE",
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# 字段定义(选项翻译/覆盖豁免用;拉取失败不阻断,只是少一层自动修正)
|
|
538
|
+
fields = _load_code_block_validation_fields(args, context)
|
|
539
|
+
|
|
540
|
+
# ① 本地静态校验(qf_output 赋值 + 基本语法)
|
|
541
|
+
static_errors = validate_code_block_static(code)
|
|
542
|
+
|
|
543
|
+
# ①' 选择类值翻译:后端只认选项 optId,传文案静默变 null——把文案自动翻成 optId。
|
|
544
|
+
key_que_values = _normalize_code_block_test_case(test_case)
|
|
545
|
+
key_que_values, select_changes, option_errors = translate_select_test_case_values(fields, key_que_values)
|
|
546
|
+
|
|
547
|
+
# ①'' 测试覆盖校验:代码引用的字段必须在 test-case 中给值(成员/部门类豁免——
|
|
548
|
+
# 其值是 uid/部门 id,模型无从得知,代码须自行 null 安全)。缺值时后端会把占位符
|
|
549
|
+
# 替换成字面量 null 静默跑通(空洞通过)——在这里拦下,逼 test-case 补全而不是放行。
|
|
550
|
+
covered_ids = _code_block_test_case_field_ids(key_que_values)
|
|
551
|
+
underivable = _underivable_field_ids(fields)
|
|
552
|
+
referenced_ids = referenced_code_block_field_ids(raw_code)
|
|
553
|
+
missing_ids = sorted((referenced_ids - covered_ids) - underivable)
|
|
554
|
+
coverage_errors = [
|
|
555
|
+
{
|
|
556
|
+
"pointer": "test-case",
|
|
557
|
+
"message": f"field queId {que_id} is referenced by code but has no test-case value "
|
|
558
|
+
f"(placeholder would run as literal null)",
|
|
559
|
+
}
|
|
560
|
+
for que_id in missing_ids
|
|
561
|
+
]
|
|
562
|
+
|
|
563
|
+
# ② 后端真跑校验(本地有错时不浪费后端调用)
|
|
564
|
+
run_result = None
|
|
565
|
+
run_error = None
|
|
566
|
+
if not static_errors and not coverage_errors and not option_errors:
|
|
567
|
+
result = context.builder.app_form_run_code_block(
|
|
568
|
+
profile=args.profile, app_key=args.app_key, code=code, key_que_values=key_que_values
|
|
569
|
+
)
|
|
570
|
+
run_result = result.get("runResult")
|
|
571
|
+
run_error = result.get("runError")
|
|
572
|
+
|
|
573
|
+
if isinstance(run_error, str):
|
|
574
|
+
run_error = restore_decimal_ids_in_text(run_error, encoded_id_map)
|
|
575
|
+
|
|
576
|
+
ok = not static_errors and not coverage_errors and not option_errors and run_result is not None
|
|
577
|
+
return {
|
|
578
|
+
"category": "validation",
|
|
579
|
+
"status": "success" if ok else "failed",
|
|
580
|
+
"ok": ok,
|
|
581
|
+
"staticErrors": static_errors,
|
|
582
|
+
"coverageErrors": coverage_errors,
|
|
583
|
+
"optionErrors": option_errors,
|
|
584
|
+
"autoFixes": {
|
|
585
|
+
"placeholderQuotesStripped": quote_fixes,
|
|
586
|
+
"selectValuesTranslated": select_changes,
|
|
587
|
+
},
|
|
588
|
+
"runResult": run_result,
|
|
589
|
+
"runError": run_error,
|
|
590
|
+
"message": "code block is valid" if ok else "code block validation failed",
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _load_code_block_validation_fields(
|
|
595
|
+
args: argparse.Namespace, context: CliContext
|
|
596
|
+
) -> list[dict] | None:
|
|
597
|
+
"""加载字段定义(类型+选项 optId),供选择类值翻译与成员/部门覆盖豁免;失败返回 None。"""
|
|
598
|
+
try:
|
|
599
|
+
fetched = context.builder.app_get_fields(profile=args.profile, app_key=args.app_key)
|
|
600
|
+
except Exception: # noqa: BLE001 - 字段拉取失败不阻断校验主流程
|
|
601
|
+
return None
|
|
602
|
+
if not isinstance(fetched, dict) or fetched.get("status") != "success":
|
|
603
|
+
return None
|
|
604
|
+
raw_fields = fetched.get("fields")
|
|
605
|
+
if not isinstance(raw_fields, list):
|
|
606
|
+
return None
|
|
607
|
+
return [field for field in raw_fields if isinstance(field, dict)]
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _underivable_field_ids(fields: list[dict] | None) -> set[int]:
|
|
611
|
+
"""值不可由模型推导的字段(成员/部门):test-case 覆盖校验豁免。"""
|
|
612
|
+
if not fields:
|
|
613
|
+
return set()
|
|
614
|
+
result: set[int] = set()
|
|
615
|
+
for field in fields:
|
|
616
|
+
field_type = str(field.get("type") or "")
|
|
617
|
+
if field_type in {"member", "department"}:
|
|
618
|
+
que_id = field.get("que_id") or field.get("queId")
|
|
619
|
+
if isinstance(que_id, int):
|
|
620
|
+
result.add(que_id)
|
|
621
|
+
return result
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _code_block_test_case_field_ids(key_que_values: list[dict]) -> set[int]:
|
|
625
|
+
"""test-case 覆盖的字段 id 集合:顶层 keyQueId + 表格 tableValues 行内 keyQueId。"""
|
|
626
|
+
covered: set[int] = set()
|
|
627
|
+
for item in key_que_values:
|
|
628
|
+
key = item.get("keyQueId")
|
|
629
|
+
if isinstance(key, int):
|
|
630
|
+
covered.add(key)
|
|
631
|
+
for row in item.get("tableValues") or []:
|
|
632
|
+
if isinstance(row, list):
|
|
633
|
+
for cell in row:
|
|
634
|
+
if isinstance(cell, dict) and isinstance(cell.get("keyQueId"), int):
|
|
635
|
+
covered.add(cell["keyQueId"])
|
|
636
|
+
return covered
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _load_fields_for_validation(args: argparse.Namespace, context: CliContext) -> list[dict] | None:
|
|
640
|
+
"""加载字段清单:优先 --fields 参数,缺省时从后端拉取应用字段。
|
|
641
|
+
|
|
642
|
+
后端拉取失败时返回 None(跳过字段引用校验,不阻断流程)。
|
|
643
|
+
"""
|
|
644
|
+
fields_path = getattr(args, "fields", None)
|
|
645
|
+
if fields_path:
|
|
646
|
+
fields = load_json_value(
|
|
647
|
+
fields_path, option_name="--fields", max_bytes=2 * 1024 * 1024, max_depth=64
|
|
648
|
+
)
|
|
649
|
+
if not isinstance(fields, list):
|
|
650
|
+
raise_config_error(
|
|
651
|
+
"--fields must be a JSON array of field objects", error_code="INVALID_FIELDS"
|
|
652
|
+
)
|
|
653
|
+
return fields
|
|
654
|
+
fetched = context.builder.app_get_fields(profile=args.profile, app_key=args.app_key)
|
|
655
|
+
if not isinstance(fetched, dict) or fetched.get("status") != "success":
|
|
656
|
+
return None
|
|
657
|
+
raw_fields = fetched.get("fields")
|
|
658
|
+
if not isinstance(raw_fields, list):
|
|
659
|
+
return None
|
|
660
|
+
return [
|
|
661
|
+
{"queId": field.get("que_id"), "name": field.get("name")}
|
|
662
|
+
for field in raw_fields
|
|
663
|
+
if isinstance(field, dict)
|
|
664
|
+
]
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _read_code_arg(code_arg: str) -> str:
|
|
668
|
+
"""读取 --code 参数:@ 前缀读文件内容,否则当文本。"""
|
|
669
|
+
if not isinstance(code_arg, str) or not code_arg:
|
|
670
|
+
raise_config_error("--code is required", error_code="INVALID_CODE")
|
|
671
|
+
if code_arg.startswith("@"):
|
|
672
|
+
path = code_arg[1:]
|
|
673
|
+
try:
|
|
674
|
+
return Path(path).expanduser().read_text(encoding="utf-8")
|
|
675
|
+
except OSError as exc:
|
|
676
|
+
raise_config_error(
|
|
677
|
+
f"failed to read --code from '{path}': {exc}", error_code="INVALID_CODE"
|
|
678
|
+
)
|
|
679
|
+
return code_arg
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _normalize_code_block_test_case(test_case: list) -> list[dict]:
|
|
683
|
+
"""将 --test-case 规范化为后端 OpenMatchQueValue 结构 [{keyQueId, ordinal?, values}]。"""
|
|
684
|
+
normalized: list[dict] = []
|
|
685
|
+
for item in test_case:
|
|
686
|
+
if not isinstance(item, dict):
|
|
687
|
+
raise_config_error(
|
|
688
|
+
"--test-case items must be JSON objects {keyQueId, ordinal?, values}",
|
|
689
|
+
error_code="INVALID_TEST_CASE",
|
|
690
|
+
)
|
|
691
|
+
raw_que_id = item.get("keyQueId", item.get("key_que_id"))
|
|
692
|
+
if raw_que_id is None:
|
|
693
|
+
raise_config_error(
|
|
694
|
+
"--test-case items require keyQueId", error_code="INVALID_TEST_CASE"
|
|
695
|
+
)
|
|
696
|
+
try:
|
|
697
|
+
key_que_id = int(raw_que_id)
|
|
698
|
+
except (TypeError, ValueError):
|
|
699
|
+
raise_config_error(
|
|
700
|
+
f"--test-case keyQueId must be an integer, got {raw_que_id!r}",
|
|
701
|
+
error_code="INVALID_TEST_CASE",
|
|
702
|
+
)
|
|
703
|
+
entry: dict = {"keyQueId": key_que_id}
|
|
704
|
+
if item.get("ordinal") is not None:
|
|
705
|
+
try:
|
|
706
|
+
entry["ordinal"] = int(item.get("ordinal"))
|
|
707
|
+
except (TypeError, ValueError):
|
|
708
|
+
raise_config_error(
|
|
709
|
+
f"--test-case ordinal must be an integer, got {item.get('ordinal')!r}",
|
|
710
|
+
error_code="INVALID_TEST_CASE",
|
|
711
|
+
)
|
|
712
|
+
values = item.get("values", [])
|
|
713
|
+
if not isinstance(values, list):
|
|
714
|
+
raise_config_error(
|
|
715
|
+
"--test-case item values must be a JSON array", error_code="INVALID_TEST_CASE"
|
|
716
|
+
)
|
|
717
|
+
entry["values"] = [str(value) for value in values]
|
|
718
|
+
if item.get("tableValues") is not None:
|
|
719
|
+
entry["tableValues"] = item.get("tableValues")
|
|
720
|
+
normalized.append(entry)
|
|
721
|
+
return normalized
|
|
722
|
+
|
|
723
|
+
|
|
398
724
|
def _handle_app_delete(args: argparse.Namespace, context: CliContext) -> dict:
|
|
399
725
|
return context.builder.app_delete(profile=args.profile, app_key=args.app_key)
|
|
400
726
|
|
|
@@ -15,6 +15,7 @@ from ..tools.export_tools import ExportTools
|
|
|
15
15
|
from ..tools.feedback_tools import FeedbackTools
|
|
16
16
|
from ..tools.file_tools import FileTools
|
|
17
17
|
from ..tools.import_tools import ImportTools
|
|
18
|
+
from ..tools.login_link_tools import LoginLinkTools
|
|
18
19
|
from ..tools.record_tools import RecordTools
|
|
19
20
|
from ..tools.resource_read_tools import ResourceReadTools
|
|
20
21
|
from ..tools.task_context_tools import TaskContextTools
|
|
@@ -27,6 +28,7 @@ class CliContext:
|
|
|
27
28
|
sessions: CliSessionFacade
|
|
28
29
|
backend: BackendClient
|
|
29
30
|
auth: AuthTools
|
|
31
|
+
login_link: LoginLinkTools
|
|
30
32
|
workspace: WorkspaceTools
|
|
31
33
|
app: AppTools
|
|
32
34
|
resource: ResourceReadTools
|
|
@@ -57,6 +59,7 @@ def build_cli_context() -> CliContext:
|
|
|
57
59
|
sessions=sessions,
|
|
58
60
|
backend=backend,
|
|
59
61
|
auth=AuthTools(sessions, backend),
|
|
62
|
+
login_link=LoginLinkTools(sessions, backend),
|
|
60
63
|
workspace=WorkspaceTools(sessions, backend),
|
|
61
64
|
app=AppTools(sessions, backend),
|
|
62
65
|
resource=ResourceReadTools(sessions, backend),
|
|
@@ -191,9 +191,10 @@ def _format_app_items(result: dict[str, Any]) -> str:
|
|
|
191
191
|
str(item.get("app_key") or item.get("appKey") or ""),
|
|
192
192
|
str(item.get("app_name") or item.get("name") or item.get("title") or ""),
|
|
193
193
|
str(item.get("package_name") or item.get("tag_name") or ""),
|
|
194
|
+
str(item.get("uri") or ""),
|
|
194
195
|
]
|
|
195
196
|
)
|
|
196
|
-
return _render_titled_table("Apps", ["app_key", "app_name", "package"], rows)
|
|
197
|
+
return _render_titled_table("Apps", ["app_key", "app_name", "package", "uri"], rows)
|
|
197
198
|
|
|
198
199
|
|
|
199
200
|
def _format_app_get(result: dict[str, Any]) -> str:
|
|
@@ -201,6 +202,7 @@ def _format_app_get(result: dict[str, Any]) -> str:
|
|
|
201
202
|
lines = [
|
|
202
203
|
f"App: {data.get('app_name') or '-'}",
|
|
203
204
|
f"App Key: {data.get('app_key') or '-'}",
|
|
205
|
+
f"URI: {data.get('uri') or '-'}",
|
|
204
206
|
f"Can Create: {data.get('can_create')}",
|
|
205
207
|
]
|
|
206
208
|
import_capability = data.get("import_capability")
|
|
@@ -224,7 +226,7 @@ def _format_app_get(result: dict[str, Any]) -> str:
|
|
|
224
226
|
lines.append(f"Accessible Views: {len(views)}")
|
|
225
227
|
for item in views[:10]:
|
|
226
228
|
if isinstance(item, dict):
|
|
227
|
-
lines.append(f"- {item.get('view_id')}: {item.get('name')}")
|
|
229
|
+
lines.append(f"- {item.get('view_id')}: {item.get('name')} / {item.get('uri') or '-'}")
|
|
228
230
|
_append_warnings(lines, result.get("warnings"))
|
|
229
231
|
return "\n".join(lines) + "\n"
|
|
230
232
|
|
|
@@ -296,6 +298,7 @@ def _format_view_get(result: dict[str, Any]) -> str:
|
|
|
296
298
|
f"View Key: {data.get('view_key') or '-'}",
|
|
297
299
|
f"App Key: {data.get('app_key') or '-'}",
|
|
298
300
|
f"View Type: {data.get('view_type') or '-'}",
|
|
301
|
+
f"URI: {data.get('uri') or '-'}",
|
|
299
302
|
f"Analysis Supported: {data.get('analysis_supported')}",
|
|
300
303
|
]
|
|
301
304
|
if export_capability:
|
|
@@ -1184,6 +1187,7 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
|
|
|
1184
1187
|
("view_key", "View Key"),
|
|
1185
1188
|
("chart_id", "Chart ID"),
|
|
1186
1189
|
("package_id", "Package ID"),
|
|
1190
|
+
("uri", "URI"),
|
|
1187
1191
|
("verified", "Verified"),
|
|
1188
1192
|
("write_executed", "Write Executed"),
|
|
1189
1193
|
("delete_executed", "Delete Executed"),
|
|
@@ -1216,6 +1220,7 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
|
|
|
1216
1220
|
"associations",
|
|
1217
1221
|
"package_ids",
|
|
1218
1222
|
"app_keys",
|
|
1223
|
+
"resources",
|
|
1219
1224
|
):
|
|
1220
1225
|
value = result.get(key)
|
|
1221
1226
|
if not isinstance(value, list) and isinstance(data, dict):
|
|
@@ -1226,6 +1231,15 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
|
|
|
1226
1231
|
lines.append("- " + _builder_item_summary(item))
|
|
1227
1232
|
if len(value) > 8:
|
|
1228
1233
|
lines.append(f"... {len(value) - 8} more")
|
|
1234
|
+
if key == "apps":
|
|
1235
|
+
uri_lines = [
|
|
1236
|
+
line
|
|
1237
|
+
for item in value
|
|
1238
|
+
for line in _builder_batch_item_uri_lines(item)
|
|
1239
|
+
]
|
|
1240
|
+
if uri_lines:
|
|
1241
|
+
lines.append("Resource URIs:")
|
|
1242
|
+
lines.extend(f"- {line}" for line in uri_lines)
|
|
1229
1243
|
components = result.get("components") if isinstance(result.get("components"), list) else []
|
|
1230
1244
|
if components:
|
|
1231
1245
|
lines.append("Component Refs:")
|
|
@@ -1294,6 +1308,7 @@ def _builder_item_summary(item: Any) -> str:
|
|
|
1294
1308
|
"uid",
|
|
1295
1309
|
"userId",
|
|
1296
1310
|
"email",
|
|
1311
|
+
"uri",
|
|
1297
1312
|
)
|
|
1298
1313
|
parts = [
|
|
1299
1314
|
f"{key}={item.get(key)}"
|
|
@@ -1305,6 +1320,50 @@ def _builder_item_summary(item: Any) -> str:
|
|
|
1305
1320
|
return " / ".join(parts) if parts else json.dumps(item, ensure_ascii=False)
|
|
1306
1321
|
|
|
1307
1322
|
|
|
1323
|
+
def _builder_batch_item_uri_lines(item: Any) -> list[str]:
|
|
1324
|
+
if not isinstance(item, dict):
|
|
1325
|
+
return []
|
|
1326
|
+
item_status = str(item.get("status") or "").lower()
|
|
1327
|
+
if item_status not in {"success", "partial_success"}:
|
|
1328
|
+
return []
|
|
1329
|
+
data = item.get("data") if isinstance(item.get("data"), dict) else item.get("result")
|
|
1330
|
+
if not isinstance(data, dict) or not _builder_uri_is_confirmed(data, fallback_status=item_status):
|
|
1331
|
+
return []
|
|
1332
|
+
lines: list[str] = []
|
|
1333
|
+
if data.get("uri"):
|
|
1334
|
+
lines.append(f"URI: {data['uri']}")
|
|
1335
|
+
views = data.get("views") if isinstance(data.get("views"), list) else []
|
|
1336
|
+
for view in views:
|
|
1337
|
+
if (
|
|
1338
|
+
isinstance(view, dict)
|
|
1339
|
+
and view.get("uri")
|
|
1340
|
+
and _builder_uri_is_confirmed(view, fallback_status=item_status)
|
|
1341
|
+
):
|
|
1342
|
+
lines.append(f"View URI: {view['uri']}")
|
|
1343
|
+
return lines
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def _builder_uri_is_confirmed(value: dict[str, Any], *, fallback_status: str) -> bool:
|
|
1347
|
+
status = str(value.get("status") or fallback_status).lower()
|
|
1348
|
+
operation = str(value.get("operation") or "").lower()
|
|
1349
|
+
return (
|
|
1350
|
+
status in {"success", "partial_success", "unchanged", "verified"}
|
|
1351
|
+
and operation
|
|
1352
|
+
not in {
|
|
1353
|
+
"failed",
|
|
1354
|
+
"pending",
|
|
1355
|
+
"planned",
|
|
1356
|
+
"readback_pending",
|
|
1357
|
+
"pending_readback",
|
|
1358
|
+
"unknown",
|
|
1359
|
+
"remove",
|
|
1360
|
+
"removed",
|
|
1361
|
+
"delete",
|
|
1362
|
+
"deleted",
|
|
1363
|
+
}
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
|
|
1308
1367
|
def _format_file_upload_local(result: dict[str, Any]) -> str:
|
|
1309
1368
|
lines = [
|
|
1310
1369
|
f"Upload Kind: {result.get('upload_kind') or result.get('requested_upload_kind') or '-'}",
|
|
@@ -1566,7 +1625,12 @@ def _task_action_debug_lines(result: dict[str, Any]) -> list[str]:
|
|
|
1566
1625
|
return lines
|
|
1567
1626
|
|
|
1568
1627
|
|
|
1628
|
+
def _format_login_link(result: dict[str, Any]) -> str:
|
|
1629
|
+
return f"登录链接:{result['url']}\n过期时间(Unix 毫秒):{result['expires_at']}\n链接只能使用一次,请勿泄露;仅在用户要求打开时才打开。\n"
|
|
1630
|
+
|
|
1631
|
+
|
|
1569
1632
|
_FORMATTERS = {
|
|
1633
|
+
"login_link": _format_login_link,
|
|
1570
1634
|
"auth_whoami": _format_whoami,
|
|
1571
1635
|
"workspace_list": _format_workspace_list,
|
|
1572
1636
|
"workspace_get": _format_workspace_get,
|
|
@@ -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,6 +33,7 @@ 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")),
|
|
@@ -127,6 +128,7 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
127
128
|
PublicToolSpec(BUILDER_DOMAIN, "auth_use_credential", ("auth_use_credential",), ("builder", "auth", "use-credential"), cli_public=False),
|
|
128
129
|
PublicToolSpec(BUILDER_DOMAIN, "auth_whoami", ("auth_whoami",), ("builder", "auth", "whoami"), cli_public=False),
|
|
129
130
|
PublicToolSpec(BUILDER_DOMAIN, "auth_logout", ("auth_logout",), ("builder", "auth", "logout"), cli_public=False),
|
|
131
|
+
PublicToolSpec(BUILDER_DOMAIN, "get_login_link", ("get_login_link",), mcp_public=True, cli_public=False),
|
|
130
132
|
PublicToolSpec(BUILDER_DOMAIN, "workspace_list", ("workspace_list",), ("builder", "workspace", "list"), cli_public=False),
|
|
131
133
|
PublicToolSpec(BUILDER_DOMAIN, "workspace_get", ("workspace_get",), ("builder", "workspace", "get"), cli_public=False),
|
|
132
134
|
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),
|
|
@@ -144,6 +146,8 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
144
146
|
PublicToolSpec(BUILDER_DOMAIN, "app_resolve", ("app_resolve",), ("builder", "app", "resolve"), has_contract=True, cli_show_effective_context=True),
|
|
145
147
|
PublicToolSpec(BUILDER_DOMAIN, "app_form_schema", ("app_form_schema",), ("builder", "app-form", "schema")),
|
|
146
148
|
PublicToolSpec(BUILDER_DOMAIN, "app_form_validate", (), ("builder", "app-form", "validate"), mcp_public=False),
|
|
149
|
+
PublicToolSpec(BUILDER_DOMAIN, "app_form_validate_formula", (), ("builder", "app-form", "validate-formula"), mcp_public=False),
|
|
150
|
+
PublicToolSpec(BUILDER_DOMAIN, "app_form_validate_code_block", (), ("builder", "app-form", "validate-code-block"), mcp_public=False),
|
|
147
151
|
PublicToolSpec(BUILDER_DOMAIN, "app_form_get", ("app_form_get",), ("builder", "app-form", "get"), cli_show_effective_context=True),
|
|
148
152
|
PublicToolSpec(BUILDER_DOMAIN, "app_form_apply", ("app_form_apply",), ("builder", "app-form", "apply"), cli_show_effective_context=True, cli_context_write=True),
|
|
149
153
|
PublicToolSpec(BUILDER_DOMAIN, "app_delete", ("app_delete",), ("builder", "app", "delete"), cli_show_effective_context=True, cli_context_write=True),
|