@josephyan/qingflow-app-user-mcp 0.2.0-beta.95 → 0.2.0-beta.97

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.95
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.97
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.95 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.97 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.95",
3
+ "version": "0.2.0-beta.97",
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.0b95"
7
+ version = "0.2.0b97"
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.0b95"
8
+ _FALLBACK_VERSION = "0.2.0b97"
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):
@@ -4635,7 +4635,19 @@ class AiBuilderFacade:
4635
4635
  )
4636
4636
  field = current_fields[matched]
4637
4637
  previous_name = field["name"]
4638
- _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
+ )
4639
4651
  current_fields[matched] = field
4640
4652
  layout = _rename_field_in_layout(layout, previous_name, field["name"])
4641
4653
  updated.append(field["name"])
@@ -10532,14 +10544,14 @@ def _parse_code_block_inputs_and_body(code_content: str) -> tuple[list[dict[str,
10532
10544
  return inputs, body
10533
10545
 
10534
10546
 
10535
- 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]:
10536
10548
  payload = {
10537
10549
  "field_id": field.get("field_id"),
10538
10550
  "que_id": field.get("que_id"),
10539
10551
  "name": field.get("name"),
10540
10552
  "type": field.get("type"),
10541
10553
  "required": bool(field.get("required")),
10542
- "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 "")),
10543
10555
  }
10544
10556
  if field.get("type") == FieldType.relation.value:
10545
10557
  payload["target_app_key"] = field.get("target_app_key")
@@ -10572,6 +10584,12 @@ def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any])
10572
10584
  payload["custom_button_text"] = field.get("custom_button_text")
10573
10585
  if field.get("metadata_unverified") is not None:
10574
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
+ ]
10575
10593
  return payload
10576
10594
 
10577
10595
 
@@ -10867,6 +10885,35 @@ def _code_block_binding_equal(left: Any, right: Any) -> bool:
10867
10885
  return _normalize_code_block_binding(left) == _normalize_code_block_binding(right)
10868
10886
 
10869
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
+
10870
10917
  def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
10871
10918
  payload = mutation.model_dump(mode="json", exclude_none=True)
10872
10919
  relation_config_explicit = (
@@ -10935,6 +10982,8 @@ def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
10935
10982
  if "subfields" in payload:
10936
10983
  field["subfields"] = [_field_patch_to_internal(item) for item in payload["subfields"]]
10937
10984
  question_rebuild_required = True
10985
+ if "subfield_updates" in payload:
10986
+ _apply_subfield_updates(field, payload["subfield_updates"])
10938
10987
  if relation_config_explicit:
10939
10988
  field["_relation_config_explicit"] = True
10940
10989
  question_rebuild_required = True
@@ -13148,22 +13197,32 @@ def _normalize_question_relations_for_save(question_relations: list[dict[str, An
13148
13197
  def _field_rename_maps(fields: list[dict[str, Any]]) -> tuple[dict[int, str], dict[str, str]]:
13149
13198
  by_que_id: dict[int, str] = {}
13150
13199
  by_title: dict[str, str] = {}
13151
- for field in fields:
13200
+
13201
+ def visit(field: dict[str, Any]) -> None:
13152
13202
  if not isinstance(field, dict):
13153
- continue
13203
+ return
13154
13204
  template = field.get("_question_template")
13155
13205
  if not isinstance(template, dict):
13156
- continue
13206
+ for subfield in cast(list[dict[str, Any]], field.get("subfields") or []):
13207
+ visit(subfield)
13208
+ return
13157
13209
  old_title = str(template.get("queTitle") or "").strip()
13158
13210
  new_title = str(field.get("name") or "").strip()
13159
13211
  if not old_title or not new_title or old_title == new_title:
13160
- continue
13212
+ for subfield in cast(list[dict[str, Any]], field.get("subfields") or []):
13213
+ visit(subfield)
13214
+ return
13161
13215
  que_id = _coerce_nonnegative_int(field.get("que_id"))
13162
13216
  if que_id is None:
13163
13217
  que_id = _coerce_nonnegative_int(template.get("queId"))
13164
13218
  if que_id is not None:
13165
13219
  by_que_id[que_id] = new_title
13166
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)
13167
13226
  return by_que_id, by_title
13168
13227
 
13169
13228
 
@@ -13200,6 +13259,20 @@ def _sync_question_title_references(value: Any, *, by_que_id: dict[int, str], by
13200
13259
  _sync_question_title_references(child_value, by_que_id=by_que_id, by_title=by_title)
13201
13260
 
13202
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
+
13203
13276
  def _materialize_preserved_question(field: dict[str, Any]) -> dict[str, Any] | None:
13204
13277
  template = deepcopy(field.get("_question_template"))
13205
13278
  if not isinstance(template, dict):
@@ -13212,6 +13285,8 @@ def _materialize_preserved_question(field: dict[str, Any]) -> dict[str, Any] | N
13212
13285
  if "description" in overlay_keys:
13213
13286
  description = field.get("description")
13214
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)
13215
13290
  if str(field.get("type") or "") == FieldType.relation.value:
13216
13291
  return _normalize_relation_question_for_save(template, field=field)
13217
13292
  return template
@@ -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",
@@ -2789,8 +2789,6 @@ class RecordTools(ToolBase):
2789
2789
  ) -> list[AccessibleViewRoute]:
2790
2790
  candidates: list[AccessibleViewRoute] = []
2791
2791
  for view_id, list_type, name in SYSTEM_VIEW_DEFINITIONS:
2792
- if not self._probe_list_type_access(context, app_key=app_key, list_type=list_type):
2793
- continue
2794
2792
  candidates.append(
2795
2793
  AccessibleViewRoute(
2796
2794
  view_id=view_id,
@@ -2807,11 +2805,22 @@ class RecordTools(ToolBase):
2807
2805
  view_key = _normalize_optional_text(item.get("viewKey"))
2808
2806
  if not view_key:
2809
2807
  continue
2810
- view_selection = self._resolve_view_selection(profile, context, app_key, view_key=view_key, view_name=None)
2811
- if view_selection is None:
2812
- continue
2813
- view_name = _normalize_optional_text(item.get("viewName")) or view_selection.view_name or view_key
2814
- view_type = _normalize_optional_text(item.get("viewType") or item.get("viewgraphType")) or view_selection.view_type
2808
+ view_name = _normalize_optional_text(item.get("viewName")) or view_key
2809
+ view_type = _normalize_optional_text(item.get("viewType") or item.get("viewgraphType"))
2810
+ filter_config: JSONObject | None = None
2811
+ if isinstance(item.get("viewgraphLimit"), list):
2812
+ filter_config = item
2813
+ else:
2814
+ raw_view_config = item.get("viewConfig")
2815
+ if isinstance(raw_view_config, dict):
2816
+ filter_config = raw_view_config
2817
+ view_selection = ViewSelection(
2818
+ view_key=view_key,
2819
+ view_name=view_name,
2820
+ conditions=_compile_view_conditions(filter_config or {}),
2821
+ view_type=view_type,
2822
+ filter_config_loaded=isinstance(filter_config, dict),
2823
+ )
2815
2824
  candidates.append(
2816
2825
  AccessibleViewRoute(
2817
2826
  view_id=f"custom:{view_key}",
@@ -7178,13 +7187,6 @@ class RecordTools(ToolBase):
7178
7187
  raise_tool_error(QingflowApiError.config_error("view_id is required; call app_get first to inspect accessible_views"))
7179
7188
 
7180
7189
  system_all_list_type = SYSTEM_VIEW_ID_TO_LIST_TYPE["system:all"]
7181
- if not self._probe_list_type_access(context, app_key=app_key, list_type=system_all_list_type):
7182
- raise_tool_error(
7183
- QingflowApiError.config_error(
7184
- "view_id is required because system:all is not accessible; call app_get first to inspect accessible_views"
7185
- )
7186
- )
7187
-
7188
7190
  return (
7189
7191
  AccessibleViewRoute(
7190
7192
  view_id="system:all",