@josephyan/qingflow-app-user-mcp 0.2.0-beta.94 → 0.2.0-beta.96

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 CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.94
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.96
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.94 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.96 qingflow-app-user-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-user-mcp",
3
- "version": "0.2.0-beta.94",
3
+ "version": "0.2.0-beta.96",
4
4
  "description": "Operational end-user MCP for Qingflow records, tasks, comments, and directory workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.2.0b94"
7
+ version = "0.2.0b96"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  __all__ = ["__version__"]
7
7
 
8
- _FALLBACK_VERSION = "0.2.0b94"
8
+ _FALLBACK_VERSION = "0.2.0b95"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -819,6 +819,10 @@ class FieldMutation(StrictModel):
819
819
  validation_alias=AliasChoices("custom_button_text", "customButtonText", "custom_btn_text", "customBtnText"),
820
820
  )
821
821
  subfields: list[FieldPatch] | None = None
822
+ subfield_updates: list["FieldUpdatePatch"] | None = Field(
823
+ default=None,
824
+ validation_alias=AliasChoices("subfield_updates", "subfieldUpdates"),
825
+ )
822
826
 
823
827
  @model_validator(mode="after")
824
828
  def validate_shape(self) -> "FieldMutation":
@@ -848,8 +852,12 @@ class FieldMutation(StrictModel):
848
852
  or self.custom_button_text is not None
849
853
  ):
850
854
  raise ValueError("code_block_config, code_block_binding, auto_trigger, custom_button_text_enabled, and custom_button_text are only allowed for code_block fields")
851
- if self.type == PublicFieldType.subtable and not self.subfields:
852
- raise ValueError("subtable field requires subfields")
855
+ if self.type == PublicFieldType.subtable and not self.subfields and not self.subfield_updates:
856
+ raise ValueError("subtable field requires subfields or subfield_updates")
857
+ if self.type is not None and self.type != PublicFieldType.subtable and self.subfield_updates:
858
+ raise ValueError("subfield_updates are only allowed for subtable fields")
859
+ if self.subfields and self.subfield_updates:
860
+ raise ValueError("subfields and subfield_updates cannot be used together")
853
861
  return self
854
862
 
855
863
  @model_validator(mode="before")
@@ -1536,6 +1544,8 @@ class PortalApplyRequest(StrictModel):
1536
1544
 
1537
1545
 
1538
1546
  FieldPatch.model_rebuild()
1547
+ FieldMutation.model_rebuild()
1548
+ FieldUpdatePatch.model_rebuild()
1539
1549
 
1540
1550
 
1541
1551
  class AppGetResponse(StrictModel):
@@ -1,12 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
3
4
  from copy import deepcopy
4
5
  from dataclasses import dataclass, field
5
6
  import json
6
7
  import os
8
+ import random
7
9
  import re
10
+ import string
8
11
  import tempfile
9
12
  from typing import Any, cast
13
+ from urllib.parse import quote_plus, unquote_plus
10
14
  from uuid import uuid4
11
15
 
12
16
  from ..backend_client import BackendRequestContext
@@ -143,6 +147,7 @@ JUDGE_EQUAL_ANY = 9
143
147
  JUDGE_FUZZY_MATCH = 19
144
148
  JUDGE_INCLUDE_ANY = 20
145
149
  DEFAULT_TYPE_RELATION = 2
150
+ DEFAULT_TYPE_FORMULA = 3
146
151
  RELATION_TYPE_Q_LINKER = 2
147
152
  RELATION_TYPE_CODE_BLOCK = 3
148
153
 
@@ -4630,7 +4635,19 @@ class AiBuilderFacade:
4630
4635
  )
4631
4636
  field = current_fields[matched]
4632
4637
  previous_name = field["name"]
4633
- _apply_field_mutation(field, patch.set)
4638
+ try:
4639
+ _apply_field_mutation(field, patch.set)
4640
+ except ValueError as error:
4641
+ return _failed(
4642
+ "VALIDATION_ERROR",
4643
+ str(error),
4644
+ normalized_args=normalized_args,
4645
+ details={
4646
+ "selector": patch.selector.model_dump(mode="json"),
4647
+ "app_key": target.app_key,
4648
+ },
4649
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
4650
+ )
4634
4651
  current_fields[matched] = field
4635
4652
  layout = _rename_field_in_layout(layout, previous_name, field["name"])
4636
4653
  updated.append(field["name"])
@@ -10527,14 +10544,14 @@ def _parse_code_block_inputs_and_body(code_content: str) -> tuple[list[dict[str,
10527
10544
  return inputs, body
10528
10545
 
10529
10546
 
10530
- def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any]:
10547
+ def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any], nested: bool = False) -> dict[str, Any]:
10531
10548
  payload = {
10532
10549
  "field_id": field.get("field_id"),
10533
10550
  "que_id": field.get("que_id"),
10534
10551
  "name": field.get("name"),
10535
10552
  "type": field.get("type"),
10536
10553
  "required": bool(field.get("required")),
10537
- "section_id": _find_field_section_id(layout, str(field.get("name") or "")),
10554
+ "section_id": None if nested else _find_field_section_id(layout, str(field.get("name") or "")),
10538
10555
  }
10539
10556
  if field.get("type") == FieldType.relation.value:
10540
10557
  payload["target_app_key"] = field.get("target_app_key")
@@ -10567,6 +10584,12 @@ def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any])
10567
10584
  payload["custom_button_text"] = field.get("custom_button_text")
10568
10585
  if field.get("metadata_unverified") is not None:
10569
10586
  payload["metadata_unverified"] = bool(field.get("metadata_unverified"))
10587
+ if field.get("type") == FieldType.subtable.value:
10588
+ payload["subfields"] = [
10589
+ _compact_public_field_read(field=subfield, layout=layout, nested=True)
10590
+ for subfield in cast(list[dict[str, Any]], field.get("subfields") or [])
10591
+ if isinstance(subfield, dict)
10592
+ ]
10570
10593
  return payload
10571
10594
 
10572
10595
 
@@ -10862,6 +10885,35 @@ def _code_block_binding_equal(left: Any, right: Any) -> bool:
10862
10885
  return _normalize_code_block_binding(left) == _normalize_code_block_binding(right)
10863
10886
 
10864
10887
 
10888
+ _SAFE_SUBFIELD_MUTATION_KEYS = frozenset({"name", "required", "description", "subfield_updates"})
10889
+
10890
+
10891
+ def _validate_safe_subfield_mutation(*, payload: dict[str, Any], location: str) -> None:
10892
+ unsupported = sorted(key for key in payload if key not in _SAFE_SUBFIELD_MUTATION_KEYS)
10893
+ if unsupported:
10894
+ raise ValueError(
10895
+ f"{location} only supports safe overlay keys: name, required, description, subfield_updates; "
10896
+ f"unsupported keys: {', '.join(unsupported)}"
10897
+ )
10898
+
10899
+
10900
+ def _apply_subfield_updates(field: dict[str, Any], raw_updates: list[Any]) -> None:
10901
+ if str(field.get("type") or "") != FieldType.subtable.value:
10902
+ raise ValueError("subfield_updates can only target subtable fields")
10903
+ subfields = [subfield for subfield in cast(list[dict[str, Any]], field.get("subfields") or []) if isinstance(subfield, dict)]
10904
+ for index, raw_item in enumerate(raw_updates):
10905
+ patch = FieldUpdatePatch.model_validate(raw_item)
10906
+ payload = patch.set.model_dump(mode="json", exclude_none=True)
10907
+ _validate_safe_subfield_mutation(payload=payload, location=f"subfield_updates[{index}].set")
10908
+ target = _resolve_field_selector_with_uniqueness(
10909
+ fields=subfields,
10910
+ selector_payload=patch.selector.model_dump(mode="json", exclude_none=True),
10911
+ location=f"subfield_updates[{index}].selector",
10912
+ )
10913
+ _apply_field_mutation(target, patch.set)
10914
+ field["subfields"] = subfields
10915
+
10916
+
10865
10917
  def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
10866
10918
  payload = mutation.model_dump(mode="json", exclude_none=True)
10867
10919
  relation_config_explicit = (
@@ -10930,6 +10982,8 @@ def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
10930
10982
  if "subfields" in payload:
10931
10983
  field["subfields"] = [_field_patch_to_internal(item) for item in payload["subfields"]]
10932
10984
  question_rebuild_required = True
10985
+ if "subfield_updates" in payload:
10986
+ _apply_subfield_updates(field, payload["subfield_updates"])
10933
10987
  if relation_config_explicit:
10934
10988
  field["_relation_config_explicit"] = True
10935
10989
  question_rebuild_required = True
@@ -12927,6 +12981,48 @@ def _copy_present_keys(
12927
12981
  return payload
12928
12982
 
12929
12983
 
12984
+ def _looks_like_backend_encoded_formula(value: str) -> bool:
12985
+ if len(value) <= 32:
12986
+ return False
12987
+ encoded = value[16:-16]
12988
+ if not encoded:
12989
+ return False
12990
+ try:
12991
+ decoded = base64.b64decode(encoded, validate=True).decode("utf-8")
12992
+ unquote_plus(decoded)
12993
+ except Exception:
12994
+ return False
12995
+ return True
12996
+
12997
+
12998
+ def _encode_formula_for_backend_save(value: Any) -> Any:
12999
+ if not isinstance(value, str) or not value:
13000
+ return value
13001
+ if _looks_like_backend_encoded_formula(value):
13002
+ return value
13003
+ encoded = quote_plus(value, encoding="utf-8")
13004
+ b64_value = base64.b64encode(encoded.encode("utf-8")).decode("ascii")
13005
+ alphabet = string.ascii_letters + string.digits
13006
+ prefix = "".join(random.choice(alphabet) for _ in range(16))
13007
+ suffix = "".join(random.choice(alphabet) for _ in range(16))
13008
+ return f"{prefix}{b64_value}{suffix}"
13009
+
13010
+
13011
+ def _normalize_formula_defaults_for_save(value: Any) -> None:
13012
+ if isinstance(value, list):
13013
+ for item in value:
13014
+ _normalize_formula_defaults_for_save(item)
13015
+ return
13016
+ if not isinstance(value, dict):
13017
+ return
13018
+ if _coerce_any_int(value.get("queDefaultType")) == DEFAULT_TYPE_FORMULA and value.get("queDefaultValue"):
13019
+ value["queDefaultValue"] = _encode_formula_for_backend_save(value.get("queDefaultValue"))
13020
+ for key in ("subQuestions", "innerQuestions"):
13021
+ nested = value.get(key)
13022
+ if isinstance(nested, (list, dict)):
13023
+ _normalize_formula_defaults_for_save(nested)
13024
+
13025
+
12930
13026
  def _normalize_reference_question_for_save(value: Any, *, ordinal: int) -> dict[str, Any] | None:
12931
13027
  if not isinstance(value, dict):
12932
13028
  return None
@@ -13090,6 +13186,8 @@ def _normalize_question_relations_for_save(question_relations: list[dict[str, An
13090
13186
  value = relation.get(key)
13091
13187
  if value is None:
13092
13188
  continue
13189
+ if key == "matchRuleFormula":
13190
+ value = _encode_formula_for_backend_save(value)
13093
13191
  item[key] = deepcopy(value)
13094
13192
  if item:
13095
13193
  normalized.append(item)
@@ -13099,22 +13197,32 @@ def _normalize_question_relations_for_save(question_relations: list[dict[str, An
13099
13197
  def _field_rename_maps(fields: list[dict[str, Any]]) -> tuple[dict[int, str], dict[str, str]]:
13100
13198
  by_que_id: dict[int, str] = {}
13101
13199
  by_title: dict[str, str] = {}
13102
- for field in fields:
13200
+
13201
+ def visit(field: dict[str, Any]) -> None:
13103
13202
  if not isinstance(field, dict):
13104
- continue
13203
+ return
13105
13204
  template = field.get("_question_template")
13106
13205
  if not isinstance(template, dict):
13107
- continue
13206
+ for subfield in cast(list[dict[str, Any]], field.get("subfields") or []):
13207
+ visit(subfield)
13208
+ return
13108
13209
  old_title = str(template.get("queTitle") or "").strip()
13109
13210
  new_title = str(field.get("name") or "").strip()
13110
13211
  if not old_title or not new_title or old_title == new_title:
13111
- continue
13212
+ for subfield in cast(list[dict[str, Any]], field.get("subfields") or []):
13213
+ visit(subfield)
13214
+ return
13112
13215
  que_id = _coerce_nonnegative_int(field.get("que_id"))
13113
13216
  if que_id is None:
13114
13217
  que_id = _coerce_nonnegative_int(template.get("queId"))
13115
13218
  if que_id is not None:
13116
13219
  by_que_id[que_id] = new_title
13117
13220
  by_title[old_title] = new_title
13221
+ for subfield in cast(list[dict[str, Any]], field.get("subfields") or []):
13222
+ visit(subfield)
13223
+
13224
+ for field in fields:
13225
+ visit(field)
13118
13226
  return by_que_id, by_title
13119
13227
 
13120
13228
 
@@ -13151,6 +13259,20 @@ def _sync_question_title_references(value: Any, *, by_que_id: dict[int, str], by
13151
13259
  _sync_question_title_references(child_value, by_que_id=by_que_id, by_title=by_title)
13152
13260
 
13153
13261
 
13262
+ def _materialize_preserved_subtable_question(field: dict[str, Any], *, template: dict[str, Any]) -> dict[str, Any] | None:
13263
+ materialized_subquestions: list[dict[str, Any]] = []
13264
+ for subfield in cast(list[dict[str, Any]], field.get("subfields") or []):
13265
+ if not isinstance(subfield, dict):
13266
+ continue
13267
+ materialized = _materialize_preserved_question(subfield)
13268
+ if materialized is None:
13269
+ return None
13270
+ materialized_subquestions.append(materialized)
13271
+ template["subQuestions"] = materialized_subquestions
13272
+ template["innerQuestions"] = [deepcopy(materialized_subquestions)]
13273
+ return template
13274
+
13275
+
13154
13276
  def _materialize_preserved_question(field: dict[str, Any]) -> dict[str, Any] | None:
13155
13277
  template = deepcopy(field.get("_question_template"))
13156
13278
  if not isinstance(template, dict):
@@ -13163,6 +13285,8 @@ def _materialize_preserved_question(field: dict[str, Any]) -> dict[str, Any] | N
13163
13285
  if "description" in overlay_keys:
13164
13286
  description = field.get("description")
13165
13287
  template["queHint"] = "" if description is None else str(description)
13288
+ if str(field.get("type") or "") == FieldType.subtable.value:
13289
+ return _materialize_preserved_subtable_question(field, template=template)
13166
13290
  if str(field.get("type") or "") == FieldType.relation.value:
13167
13291
  return _normalize_relation_question_for_save(template, field=field)
13168
13292
  return template
@@ -13412,6 +13536,7 @@ def _build_form_payload_from_fields(
13412
13536
  for row in form_rows:
13413
13537
  _apply_row_widths(row)
13414
13538
  payload = default_form_payload(title, form_rows)
13539
+ _normalize_formula_defaults_for_save(payload.get("formQues"))
13415
13540
  payload["editVersionNo"] = int(current_schema.get("editVersionNo") or 1)
13416
13541
  payload["questionRelations"] = _normalize_question_relations_for_save(
13417
13542
  question_relations if question_relations is not None else (current_schema.get("questionRelations") or [])
@@ -13529,6 +13654,7 @@ def _build_form_payload_for_edit_fields(
13529
13654
 
13530
13655
  payload = _build_form_save_base_payload(current_schema, title)
13531
13656
  payload["formQues"] = form_rows
13657
+ _normalize_formula_defaults_for_save(payload.get("formQues"))
13532
13658
  payload["questionRelations"] = normalized_relations
13533
13659
  payload["editVersionNo"] = int(current_schema.get("editVersionNo") or 1)
13534
13660
  return payload
@@ -14945,6 +15071,7 @@ def _build_form_payload_from_existing_schema(
14945
15071
 
14946
15072
  payload = _build_form_save_base_payload(current_schema, str(current_schema.get("formTitle") or "未命名应用"))
14947
15073
  payload["formQues"] = form_rows
15074
+ _normalize_formula_defaults_for_save(payload.get("formQues"))
14948
15075
  payload["questionRelations"] = _normalize_question_relations_for_save(current_schema.get("questionRelations") or [])
14949
15076
  payload["editVersionNo"] = int(current_schema.get("editVersionNo") or 1)
14950
15077
  payload.setdefault("formTitle", current_schema.get("formTitle") or "未命名应用")
@@ -101,7 +101,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
101
101
  app_release_lock.add_argument("--lock-owner-name", required=True)
102
102
  app_release_lock.set_defaults(handler=_handle_app_release_edit_lock_if_mine, format_hint="builder_summary")
103
103
 
104
- app_get = app_subparsers.add_parser("get", help="读取应用配置")
104
+ app_get = app_subparsers.add_parser("get", help="读取应用配置(字段请使用: builder app get --app-key APP fields)")
105
105
  app_get.add_argument(
106
106
  "builder_app_get_section",
107
107
  nargs="?",
@@ -2550,6 +2550,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2550
2550
  "field.autoTrigger": "field.auto_trigger",
2551
2551
  "field.customBtnTextStatus": "field.custom_button_text_enabled",
2552
2552
  "field.customBtnText": "field.custom_button_text",
2553
+ "field.subfieldUpdates": "field.subfield_updates",
2553
2554
  },
2554
2555
  "allowed_values": {
2555
2556
  "field.type": [member.value for member in PublicFieldType],
@@ -2636,6 +2637,9 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2636
2637
  "relation_mode=multiple maps to referenceConfig.optionalDataNum=0",
2637
2638
  "relation fields now require both display_field and visible_fields in MCP/CLI payloads",
2638
2639
  "if relation target metadata lookup is blocked by 40161/40002/40027, explicit display_field.name and visible_fields[].name let builder degrade verification and still continue schema write",
2640
+ "update_fields[].set.subfield_updates is the safe patch path for editing existing subtable child fields without rebuilding the entire subtable",
2641
+ "subfield_updates only supports safe child overlays: name, required, description, and nested subfield_updates",
2642
+ "set.subfields remains the full replace/rebuild path for a subtable and is higher risk when hidden relation/reference children exist",
2639
2643
  "department fields accept department_scope with mode=all or mode=custom; custom scope requires explicit departments[].dept_id and optional include_sub_departs",
2640
2644
  "q_linker_binding lets you declare request config, dynamic inputs, alias parsing, and target-field bindings in one step; builder writes remoteLookupConfig plus the existing backend relation-default and questionRelations structures",
2641
2645
  "code_block_binding lets you declare inputs, code, alias parsing, and target-field bindings in one step; builder writes codeBlockConfig plus the existing backend relation-default and questionRelations structures",
@@ -2683,6 +2687,26 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2683
2687
  "update_fields": [],
2684
2688
  "remove_fields": [],
2685
2689
  },
2690
+ "subfield_update_example": {
2691
+ "profile": "default",
2692
+ "app_key": "APP_REWARD",
2693
+ "publish": True,
2694
+ "add_fields": [],
2695
+ "update_fields": [
2696
+ {
2697
+ "selector": {"que_id": 441305044},
2698
+ "set": {
2699
+ "subfield_updates": [
2700
+ {
2701
+ "selector": {"que_id": 441305045},
2702
+ "set": {"name": "景品名"},
2703
+ }
2704
+ ]
2705
+ },
2706
+ }
2707
+ ],
2708
+ "remove_fields": [],
2709
+ },
2686
2710
  "department_scope_example": {
2687
2711
  "profile": "default",
2688
2712
  "app_key": "APP_LEAD",
@@ -3049,6 +3073,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3049
3073
  "execution_notes": [
3050
3074
  "returns compact current field configuration for one app",
3051
3075
  "use this before app_schema_apply when you need exact field definitions",
3076
+ "subtable fields include nested subfields using the same compact field shape",
3052
3077
  ],
3053
3078
  "minimal_example": {
3054
3079
  "profile": "default",