@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.
@@ -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 AppFormValidationError, validate_declaration
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: