@qingflow-tech/qingflow-cli 1.1.45 → 1.1.47-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/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/SKILL.md +16 -2
- package/skills/qingflow-cli/reference/builder/90-workflow.md +4 -0
- package/skills/qingflow-cli/reference/builder/workflow/01-overview.md +3 -3
- package/skills/qingflow-cli/reference/builder/workflow/02-update-mode.md +2 -0
- package/skills/qingflow-common/SKILL.md +17 -1
- package/src/qingflow_mcp/app_form_validator.py +304 -0
- package/src/qingflow_mcp/builder_facade/service.py +5 -0
- package/src/qingflow_mcp/cli/commands/auth.py +8 -0
- package/src/qingflow_mcp/cli/commands/builder.py +368 -1
- package/src/qingflow_mcp/cli/context.py +3 -0
- package/src/qingflow_mcp/cli/formatters.py +5 -0
- package/src/qingflow_mcp/config.py +39 -0
- package/src/qingflow_mcp/public_surface.py +3 -0
- package/src/qingflow_mcp/response_trim.py +14 -0
- package/src/qingflow_mcp/server.py +2 -0
- package/src/qingflow_mcp/server_app_user.py +17 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +0 -1
- package/src/qingflow_mcp/tools/ai_builder_tools.py +58 -0
- package/src/qingflow_mcp/tools/login_link_tools.py +114 -0
|
@@ -2,13 +2,32 @@ 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
|
|
|
11
25
|
|
|
26
|
+
# BEGIN temporary flow edit maintenance guard: remove this block when flow editing is restored.
|
|
27
|
+
FLOW_EDIT_MAINTENANCE_NODE_THRESHOLD = 5
|
|
28
|
+
# END temporary flow edit maintenance guard.
|
|
29
|
+
|
|
30
|
+
|
|
12
31
|
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
13
32
|
parser = subparsers.add_parser("builder", aliases=["build"], help="稳定 builder 命令")
|
|
14
33
|
builder_subparsers = parser.add_subparsers(dest="builder_command", required=True)
|
|
@@ -123,6 +142,41 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
123
142
|
app_form_validate.add_argument("--schema-version", help="固定使用的 AppForm Schema 版本")
|
|
124
143
|
app_form_validate.set_defaults(handler=_handle_app_form_validate, format_hint="builder_summary", force_json_output=True)
|
|
125
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
|
+
|
|
126
180
|
app = builder_subparsers.add_parser("app", help="应用")
|
|
127
181
|
app_subparsers = app.add_subparsers(dest="builder_app_command", required=True)
|
|
128
182
|
|
|
@@ -390,6 +444,283 @@ def _handle_app_form_validate(args: argparse.Namespace, _context: CliContext) ->
|
|
|
390
444
|
return {"category": "validation", "status": "success", "ok": True, **result, "message": "AppForm declaration is valid"}
|
|
391
445
|
|
|
392
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
|
+
|
|
393
724
|
def _handle_app_delete(args: argparse.Namespace, context: CliContext) -> dict:
|
|
394
725
|
return context.builder.app_delete(profile=args.profile, app_key=args.app_key)
|
|
395
726
|
|
|
@@ -933,6 +1264,30 @@ def _handle_flow_apply(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
933
1264
|
"builder flow apply requires exactly one input mode: --spec-file or --patch-nodes-file.",
|
|
934
1265
|
fix_hint="Use --spec-file for a complete WorkflowSpec, or --patch-nodes-file for targeted edits after builder flow get.",
|
|
935
1266
|
)
|
|
1267
|
+
# BEGIN temporary flow edit maintenance guard: remove this block when flow editing is restored.
|
|
1268
|
+
current_flow = context.builder.app_flow_get(profile=args.profile, app_key=args.app_key)
|
|
1269
|
+
if str(current_flow.get("status") or "").lower() != "success":
|
|
1270
|
+
return current_flow
|
|
1271
|
+
current_node_count = _flow_spec_node_count(current_flow)
|
|
1272
|
+
if current_node_count is None:
|
|
1273
|
+
raise_config_error(
|
|
1274
|
+
"无法读取当前流程节点,未执行流程变更。",
|
|
1275
|
+
fix_hint="当前流程状态无法确认,未执行流程变更。请稍后重试。",
|
|
1276
|
+
error_code="FLOW_STATE_UNAVAILABLE",
|
|
1277
|
+
details={"app_key": args.app_key},
|
|
1278
|
+
)
|
|
1279
|
+
if current_node_count > FLOW_EDIT_MAINTENANCE_NODE_THRESHOLD:
|
|
1280
|
+
raise_config_error(
|
|
1281
|
+
"功能维护中,暂时不允许处理",
|
|
1282
|
+
fix_hint="当前流程节点超过 5 个,流程编辑功能维护中;请稍后重试。",
|
|
1283
|
+
error_code="FLOW_EDIT_UNDER_MAINTENANCE",
|
|
1284
|
+
details={
|
|
1285
|
+
"app_key": args.app_key,
|
|
1286
|
+
"current_node_count": current_node_count,
|
|
1287
|
+
"maintenance_node_threshold": FLOW_EDIT_MAINTENANCE_NODE_THRESHOLD,
|
|
1288
|
+
},
|
|
1289
|
+
)
|
|
1290
|
+
# END temporary flow edit maintenance guard.
|
|
936
1291
|
if patch_nodes:
|
|
937
1292
|
return context.builder.app_flow_apply(
|
|
938
1293
|
profile=args.profile,
|
|
@@ -957,6 +1312,18 @@ def _handle_flow_apply(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
957
1312
|
)
|
|
958
1313
|
|
|
959
1314
|
|
|
1315
|
+
# BEGIN temporary flow edit maintenance guard: remove this helper when flow editing is restored.
|
|
1316
|
+
def _flow_spec_node_count(flow_result: dict) -> int | None:
|
|
1317
|
+
spec = flow_result.get("spec")
|
|
1318
|
+
if not isinstance(spec, dict):
|
|
1319
|
+
return None
|
|
1320
|
+
nodes = spec.get("nodes")
|
|
1321
|
+
return len(nodes) if isinstance(nodes, list) else None
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
# END temporary flow edit maintenance guard.
|
|
1325
|
+
|
|
1326
|
+
|
|
960
1327
|
def _handle_flow_schema(args: argparse.Namespace, context: CliContext) -> dict:
|
|
961
1328
|
return context.builder.app_flow_get_schema(profile=args.profile, schema_version=args.schema_version)
|
|
962
1329
|
|
|
@@ -10,6 +10,7 @@ from ..session_store import SessionStore
|
|
|
10
10
|
from ..tools.ai_builder_tools import AiBuilderTools
|
|
11
11
|
from ..tools.app_tools import AppTools
|
|
12
12
|
from ..tools.auth_tools import AuthTools
|
|
13
|
+
from ..tools.login_link_tools import LoginLinkTools
|
|
13
14
|
from ..tools.code_block_tools import CodeBlockTools
|
|
14
15
|
from ..tools.export_tools import ExportTools
|
|
15
16
|
from ..tools.feedback_tools import FeedbackTools
|
|
@@ -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),
|
|
@@ -1566,7 +1566,12 @@ def _task_action_debug_lines(result: dict[str, Any]) -> list[str]:
|
|
|
1566
1566
|
return lines
|
|
1567
1567
|
|
|
1568
1568
|
|
|
1569
|
+
def _format_login_link(result: dict[str, Any]) -> str:
|
|
1570
|
+
return f"登录链接:{result['url']}\n过期时间(Unix 毫秒):{result['expires_at']}\n链接仅可使用一次,请勿泄露。\n"
|
|
1571
|
+
|
|
1572
|
+
|
|
1569
1573
|
_FORMATTERS = {
|
|
1574
|
+
"login_link": _format_login_link,
|
|
1570
1575
|
"auth_whoami": _format_whoami,
|
|
1571
1576
|
"workspace_list": _format_workspace_list,
|
|
1572
1577
|
"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")),
|
|
@@ -144,6 +145,8 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
144
145
|
PublicToolSpec(BUILDER_DOMAIN, "app_resolve", ("app_resolve",), ("builder", "app", "resolve"), has_contract=True, cli_show_effective_context=True),
|
|
145
146
|
PublicToolSpec(BUILDER_DOMAIN, "app_form_schema", ("app_form_schema",), ("builder", "app-form", "schema")),
|
|
146
147
|
PublicToolSpec(BUILDER_DOMAIN, "app_form_validate", (), ("builder", "app-form", "validate"), mcp_public=False),
|
|
148
|
+
PublicToolSpec(BUILDER_DOMAIN, "app_form_validate_formula", (), ("builder", "app-form", "validate-formula"), mcp_public=False),
|
|
149
|
+
PublicToolSpec(BUILDER_DOMAIN, "app_form_validate_code_block", (), ("builder", "app-form", "validate-code-block"), mcp_public=False),
|
|
147
150
|
PublicToolSpec(BUILDER_DOMAIN, "app_form_get", ("app_form_get",), ("builder", "app-form", "get"), cli_show_effective_context=True),
|
|
148
151
|
PublicToolSpec(BUILDER_DOMAIN, "app_form_apply", ("app_form_apply",), ("builder", "app-form", "apply"), cli_show_effective_context=True, cli_context_write=True),
|
|
149
152
|
PublicToolSpec(BUILDER_DOMAIN, "app_delete", ("app_delete",), ("builder", "app", "delete"), cli_show_effective_context=True, cli_context_write=True),
|
|
@@ -51,6 +51,10 @@ COMMON_ERROR_DROP_TOP = {
|
|
|
51
51
|
|
|
52
52
|
SUCCESS_POLICY_BY_TOOL: dict[str, TransformFn] = {}
|
|
53
53
|
|
|
54
|
+
#: 这两个校验命令的产出契约明确包含 ok 字段(供阶段 2 SKILL.md 引用),
|
|
55
|
+
#: 需在 success 裁剪时保留 ok(默认 COMMON_SUCCESS_DROP_TOP 会丢弃 ok)。
|
|
56
|
+
_KEEP_OK_TOOLS = {"builder:app_form_validate_formula", "builder:app_form_validate_code_block"}
|
|
57
|
+
|
|
54
58
|
|
|
55
59
|
def trim_public_response(tool_name: str | None, payload: dict[str, Any]) -> dict[str, Any]:
|
|
56
60
|
if not isinstance(payload, dict):
|
|
@@ -85,6 +89,8 @@ def trim_success_response(tool_name: str | None, payload: dict[str, Any]) -> dic
|
|
|
85
89
|
drop_keys = COMMON_SUCCESS_DROP_TOP - {"output_profile"}
|
|
86
90
|
if tool_name in {"user:record_insert", "user:record_update", "user:record_delete"} and payload.get("ok") is False:
|
|
87
91
|
drop_keys = drop_keys - {"ok"}
|
|
92
|
+
if tool_name in _KEEP_OK_TOOLS:
|
|
93
|
+
drop_keys = drop_keys - {"ok"}
|
|
88
94
|
if tool_name == "builder:app_form_apply" and payload.get("mustReadCurrentStateBeforeRepair") is True:
|
|
89
95
|
drop_keys = drop_keys - {"suggested_next_call"}
|
|
90
96
|
_drop_top_keys(trimmed, drop_keys)
|
|
@@ -1128,6 +1134,13 @@ def _summarize_form_diff(diff: dict[str, Any]) -> dict[str, Any]:
|
|
|
1128
1134
|
return summary
|
|
1129
1135
|
|
|
1130
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
|
+
|
|
1131
1144
|
def _register_policy(domains: tuple[str, ...], names: tuple[str, ...], transform: TransformFn) -> None:
|
|
1132
1145
|
for domain in domains:
|
|
1133
1146
|
for name in names:
|
|
@@ -1135,6 +1148,7 @@ def _register_policy(domains: tuple[str, ...], names: tuple[str, ...], transform
|
|
|
1135
1148
|
|
|
1136
1149
|
|
|
1137
1150
|
_register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("auth_use_credential", "auth_whoami"), _trim_auth_payload)
|
|
1151
|
+
_register_policy((USER_DOMAIN,), ("get_login_link",), _trim_login_link)
|
|
1138
1152
|
_register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("auth_logout",), _trim_auth_logout)
|
|
1139
1153
|
_register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("workspace_list",), _trim_workspace_list)
|
|
1140
1154
|
_register_policy((USER_DOMAIN, BUILDER_DOMAIN), ("workspace_get", "workspace_select"), _trim_workspace_get)
|
|
@@ -13,6 +13,7 @@ from .tools.feedback_tools import FeedbackTools
|
|
|
13
13
|
from .tools.export_tools import ExportTools
|
|
14
14
|
from .tools.file_tools import FileTools
|
|
15
15
|
from .tools.import_tools import ImportTools
|
|
16
|
+
from .tools.login_link_tools import LoginLinkTools
|
|
16
17
|
from .tools.package_tools import PackageTools
|
|
17
18
|
from .tools.navigation_tools import NavigationTools
|
|
18
19
|
from .tools.directory_tools import DirectoryTools
|
|
@@ -238,6 +239,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
|
|
|
238
239
|
sessions = SessionStore()
|
|
239
240
|
backend = BackendClient()
|
|
240
241
|
AuthTools(sessions, backend).register(server)
|
|
242
|
+
LoginLinkTools(sessions, backend).register(server)
|
|
241
243
|
FeedbackTools(backend, mcp_side="通用").register(server)
|
|
242
244
|
WorkspaceTools(sessions, backend).register(server)
|
|
243
245
|
FileTools(sessions, backend).register(server)
|
|
@@ -17,6 +17,7 @@ from .tools.export_tools import ExportTools
|
|
|
17
17
|
from .tools.feedback_tools import FeedbackTools
|
|
18
18
|
from .tools.file_tools import FileTools
|
|
19
19
|
from .tools.import_tools import ImportTools
|
|
20
|
+
from .tools.login_link_tools import LOGIN_LINK_DESCRIPTION, LoginLinkTools
|
|
20
21
|
from .tools.resource_read_tools import ResourceReadTools
|
|
21
22
|
from .tools.task_context_tools import TaskContextTools
|
|
22
23
|
from .tools.workspace_tools import WorkspaceTools
|
|
@@ -30,6 +31,16 @@ def build_user_server(*, stateless_http: bool = False) -> FastMCP:
|
|
|
30
31
|
stateless_http=stateless_http,
|
|
31
32
|
instructions=f"""Use this server for Qingflow operational workflows. Current date: `{today.isoformat()}`.
|
|
32
33
|
|
|
34
|
+
## 页面交接
|
|
35
|
+
|
|
36
|
+
用户需要查看结果、继续编辑、处理业务或进入工作区页面时,使用 `get_login_link`;纯数据查询不自动签发链接。
|
|
37
|
+
目标路径由业务工具或领域 Skill 根据真实前端路由确定,不由票据工具根据业务标识推导。
|
|
38
|
+
已知目标传可选 `redirect_path`;明确指定页面但路径未知时继续定位或说明限制,不猜测,也不静默改为首页。
|
|
39
|
+
搜索未命中时继续搜索 `login` 或查看服务工具列表,不擅自打开固定正式站点;鉴权或工具失败时如实说明。
|
|
40
|
+
临近交付时再签发,使用返回的完整 URL。要求“给我链接”时仅交付,不提前消费;要求“打开”时才调用浏览器。
|
|
41
|
+
浏览器不可用时提供可点击链接;用“查看记录”“继续编辑”等文案,不展示工作区数字 ID 或原始 JSON。
|
|
42
|
+
链接约 5 分钟内一次性有效,不能作为长期书签;安全验证及原有业务权限不变。
|
|
43
|
+
|
|
33
44
|
## App Discovery
|
|
34
45
|
|
|
35
46
|
If `app_key` is unknown, use `app_list` first. Pass `query` to filter visible apps by keyword.
|
|
@@ -237,6 +248,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
|
|
|
237
248
|
code_block_tools = wrap_trimmed_methods(CodeBlockTools(sessions, backend), USER_SERVER_METHOD_MAP)
|
|
238
249
|
task_context_tools = wrap_trimmed_methods(TaskContextTools(sessions, backend), USER_SERVER_METHOD_MAP)
|
|
239
250
|
directory_tools = wrap_trimmed_methods(DirectoryTools(sessions, backend), USER_SERVER_METHOD_MAP)
|
|
251
|
+
login_link = LoginLinkTools(sessions, backend)
|
|
240
252
|
|
|
241
253
|
@server.tool()
|
|
242
254
|
def auth_use_credential(
|
|
@@ -258,6 +270,11 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
|
|
|
258
270
|
def auth_whoami(profile: str = DEFAULT_PROFILE) -> dict:
|
|
259
271
|
return auth.auth_whoami(profile=profile)
|
|
260
272
|
|
|
273
|
+
@server.tool(description=LOGIN_LINK_DESCRIPTION)
|
|
274
|
+
def get_login_link(profile: str = DEFAULT_PROFILE, redirect_path: str = "") -> dict:
|
|
275
|
+
"""获取一次性免登录链接;redirect_path 可选,仅接受站内业务路径,省略进入首页。"""
|
|
276
|
+
return login_link.get_login_link(profile=profile, redirect_path=redirect_path)
|
|
277
|
+
|
|
261
278
|
@server.tool()
|
|
262
279
|
def auth_logout(profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict:
|
|
263
280
|
return auth.auth_logout(profile=profile, forget_persisted=forget_persisted)
|
|
@@ -242,7 +242,6 @@ def build_question(field: dict[str, Any], temp_id: int) -> tuple[dict[str, Any],
|
|
|
242
242
|
{
|
|
243
243
|
"referenceConfig": build_reference_config(field, temp_id),
|
|
244
244
|
"queOriginType": 25,
|
|
245
|
-
"queDefaultType": 2,
|
|
246
245
|
}
|
|
247
246
|
)
|
|
248
247
|
if field_type == FieldType.code_block:
|