@josephyan/qingflow-app-user-mcp 1.0.7 → 1.0.8

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@1.0.7
6
+ npm install @josephyan/qingflow-app-user-mcp@1.0.8
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-user-mcp@1.0.7 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@1.0.8 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": "1.0.7",
3
+ "version": "1.0.8",
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 = "1.0.7"
7
+ version = "1.0.8"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -8,6 +8,7 @@ It is not a user-facing product spec. It exists to prevent skill drift.
8
8
  ### User data
9
9
 
10
10
  - Read range first with `app_get`, then `record_browse_schema_get(view_id=...)`
11
+ - Treat `record_browse_schema_get.fields` as the selected Qingflow table view header schema; `record_access.fields` and `schema.json` must stay aligned with it.
11
12
  - Standard flows:
12
13
  - analyze: `app_get -> record_browse_schema_get -> record_access -> Python`
13
14
  - browse detail: `app_get -> record_browse_schema_get -> record_list / record_get`
@@ -9,7 +9,7 @@ metadata:
9
9
 
10
10
  This skill is for final statistical conclusions only.
11
11
  Assumes MCP is connected, authenticated, and on the correct workspace.
12
- Analysis tasks must start with `app_get`, then `record_browse_schema_get(view_id=...)`. Read top-level `fields` and `suggested_*`, then choose field_id-based columns and filters only.
12
+ Analysis tasks must start with `app_get`, then `record_browse_schema_get(view_id=...)`. `record_browse_schema_get.fields` is the selected Qingflow table view's readable header schema and is the same field source used by `record_access.fields` / `schema.json`. Read top-level `fields` and `suggested_*`, then choose field_id-based columns and filters only.
13
13
  If `app_get.accessible_views` marks a view with `analysis_supported=false`, do not use that view for `record_access`, `record_list`, or `record_analyze`. `boardView` and `ganttView` are special UI views, not data-access targets.
14
14
 
15
15
  ## Step 1: `app_get` → Step 2: `record_browse_schema_get(view_id=...)` → Step 3: `record_access` → Step 4: Python
@@ -76,6 +76,7 @@ Use `record_access` to fetch detail rows into local CSV shards. It does not anal
76
76
  Then run Python against every `files[].local_path`. CSV columns are stable: `record_id`, then `field_<field_id>`. Use `fields[]` plus `metadata_files.schema` / `metadata_files.readme` in the same output directory to map titles and types, especially when reusing the CSV later.
77
77
 
78
78
  - Never ask for `page`, `page_size`, `limit`, or `max_rows`; `record_access` owns paging internally and follows the backend's native paging capability.
79
+ - Never treat backend `searchQueIds` as column selection; it is only a full-text search scope.
79
80
  - If multiple CSV files are returned, read them all.
80
81
  - If `complete=false` or `safe_for_final_conclusion=false`, downgrade the answer and disclose the limitation.
81
82
  - `record_export_direct` is only for explicit export/download/Excel requests, not default analysis.
@@ -33,7 +33,7 @@ Result reading order:
33
33
  6. `record_access.fields`
34
34
  7. `record_access.warnings`
35
35
 
36
- Treat `record_browse_schema_get` as the browse-schema source of truth. Missing fields are permission boundaries, not invitations to guess hidden ids.
36
+ Treat `record_browse_schema_get` as the browse-schema source of truth. It matches the selected Qingflow table view header and is the same schema source used by `record_access.fields` and `schema.json`. Missing fields are permission boundaries, not invitations to guess hidden ids.
37
37
 
38
38
  ## Lightweight `record_analyze` helper sequence
39
39
 
@@ -60,7 +60,9 @@ Call `record_import_schema_get` when the import field mapping is unclear before
60
60
  `record_insert_schema_get` returns the current user's insert-ready applicant schema; read `required_fields`, `optional_fields`, `runtime_linked_required_fields`, and `payload_template`.
61
61
  Inside `optional_fields`, any field with `may_become_required=true` is still writable, but may become required when linked visibility or option-driven runtime rules activate.
62
62
  `record_update_schema_get` returns the current record's overall update-ready writable field set across matched accessible views; read `writable_fields` and `payload_template`.
63
- `record_browse_schema_get(view_id=...)` returns browse-schema fields for the selected accessible view.
63
+ `record_browse_schema_get(view_id=...)` returns the same readable fields shown in the selected Qingflow table view header.
64
+ `record_access.fields` and its `schema.json` use that exact same view schema; a missing field means it is not readable in that view.
65
+ `searchQueIds` is a backend full-text search scope, not an output-column/projection mechanism.
64
66
  `record_code_block_schema_get` returns code-block-ready schema for exact code block field selection.
65
67
  `record_import_schema_get` returns import-ready column metadata.
66
68
 
@@ -602,7 +602,7 @@ class RecordTools(ToolBase):
602
602
  ),
603
603
  }
604
604
  )
605
- browse_scope = self._build_browse_write_scope(
605
+ browse_scope = self._build_browse_read_scope(
606
606
  profile,
607
607
  context,
608
608
  app_key,
@@ -1848,25 +1848,20 @@ class RecordTools(ToolBase):
1848
1848
  sorts = self._normalize_record_list_order_by(order_by)
1849
1849
 
1850
1850
  def runner(session_profile, context):
1851
- index = self._get_field_index(profile, context, app_key, force_refresh=False)
1852
- selected_fields = self._resolve_select_columns(
1851
+ browse_scope = self._build_browse_read_scope(
1852
+ profile,
1853
+ context,
1854
+ app_key,
1855
+ view_route,
1856
+ force_refresh=False,
1857
+ )
1858
+ index = cast(FieldIndex, browse_scope["index"])
1859
+ selected_fields = self._resolve_record_access_columns(
1853
1860
  normalized_columns,
1854
1861
  index,
1855
- max_columns=len(normalized_columns),
1856
- default_limit=MAX_LIST_COLUMN_LIMIT,
1862
+ view_route=view_route,
1857
1863
  )
1858
- selected_field_batches = _chunk_fields(selected_fields, BACKEND_LIST_SEARCH_FIELD_LIMIT)
1859
- primary_search_que_ids: list[int] | None = [field.que_id for field in selected_field_batches[0]]
1860
1864
  view_selection = view_route.view_selection
1861
- if view_selection is not None and not _view_selection_supported_by_search_ids(
1862
- view_selection,
1863
- primary_search_que_ids,
1864
- ):
1865
- primary_search_que_ids = None
1866
- remaining_field_batches: list[list[FormField]] = []
1867
- else:
1868
- remaining_field_batches = selected_field_batches[1:]
1869
- primary_search_que_ids = primary_search_que_ids or None
1870
1865
  match_rules = self._resolve_match_rules(context, filters, index)
1871
1866
  sort_rules = self._resolve_sorts(sorts, index)
1872
1867
  used_list_type: int | None = None
@@ -1949,7 +1944,7 @@ class RecordTools(ToolBase):
1949
1944
  query_key=None,
1950
1945
  match_rules=match_rules,
1951
1946
  sorts=sort_rules,
1952
- search_que_ids=primary_search_que_ids,
1947
+ search_que_ids=None,
1953
1948
  list_type=candidate_list_type,
1954
1949
  )
1955
1950
  used_list_type = None if view_selection is not None else candidate_list_type
@@ -1976,7 +1971,7 @@ class RecordTools(ToolBase):
1976
1971
  query_key=None,
1977
1972
  match_rules=match_rules,
1978
1973
  sorts=sort_rules,
1979
- search_que_ids=primary_search_que_ids,
1974
+ search_que_ids=None,
1980
1975
  list_type=used_list_type,
1981
1976
  )
1982
1977
  page_rows = page.get("list")
@@ -1996,34 +1991,6 @@ class RecordTools(ToolBase):
1996
1991
  continue
1997
1992
  page_apply_order.append(apply_id)
1998
1993
  page_answer_map[apply_id] = cast(list[JSONValue], answer_list)
1999
- if page_apply_order and remaining_field_batches:
2000
- for batch in remaining_field_batches:
2001
- extra_page = self._search_page(
2002
- context,
2003
- app_key=app_key,
2004
- view_selection=view_selection,
2005
- page_num=current_page,
2006
- page_size=BACKEND_RECORD_ACCESS_PAGE_SIZE,
2007
- query_key=None,
2008
- match_rules=match_rules,
2009
- sorts=sort_rules,
2010
- search_que_ids=[field.que_id for field in batch],
2011
- list_type=used_list_type or DEFAULT_RECORD_LIST_TYPE,
2012
- )
2013
- extra_rows = extra_page.get("list")
2014
- extra_items = extra_rows if isinstance(extra_rows, list) else []
2015
- for extra_item in extra_items:
2016
- if not isinstance(extra_item, dict):
2017
- continue
2018
- apply_id = _coerce_count(extra_item.get("applyId")) or _coerce_count(extra_item.get("id"))
2019
- if apply_id is None or apply_id not in page_answer_map:
2020
- continue
2021
- extra_answers = extra_item.get("answers")
2022
- extra_answer_list = extra_answers if isinstance(extra_answers, list) else []
2023
- page_answer_map[apply_id] = _merge_answer_lists_by_field_id(
2024
- page_answer_map.get(apply_id, []),
2025
- cast(list[JSONValue], extra_answer_list),
2026
- )
2027
1994
  for apply_id in page_apply_order:
2028
1995
  write_record(apply_id, page_answer_map.get(apply_id, []))
2029
1996
  if not has_more:
@@ -7427,6 +7394,39 @@ class RecordTools(ToolBase):
7427
7394
  self._form_cache[cache_key] = normalized
7428
7395
  return normalized
7429
7396
 
7397
+ def _get_system_browse_base_info_schema(
7398
+ self,
7399
+ profile: str,
7400
+ context, # type: ignore[no-untyped-def]
7401
+ app_key: str,
7402
+ *,
7403
+ list_type: int,
7404
+ force_refresh: bool,
7405
+ ) -> JSONObject:
7406
+ """Return system view apply/baseInfo without falling back to app form metadata."""
7407
+ cache_key = (profile, app_key, "browse_system_base_info", list_type)
7408
+ if not force_refresh and cache_key in self._form_cache:
7409
+ return self._form_cache[cache_key]
7410
+ payload = self.backend.request(
7411
+ "GET",
7412
+ context,
7413
+ f"/app/{app_key}/apply/baseInfo",
7414
+ params={"type": list_type},
7415
+ )
7416
+ normalized = _normalize_data_list_base_info_schema(payload)
7417
+ self._form_cache[cache_key] = normalized
7418
+ return normalized
7419
+
7420
+ def _get_custom_view_browse_schema(self, profile: str, context, view_key: str, *, force_refresh: bool) -> JSONObject: # type: ignore[no-untyped-def]
7421
+ """Return the same baseInfo schema used by the Qingflow table view UI."""
7422
+ cache_key = (profile, f"view:{view_key}", "browse_view_base_info", None)
7423
+ if not force_refresh and cache_key in self._form_cache:
7424
+ return self._form_cache[cache_key]
7425
+ payload = self.backend.request("GET", context, f"/view/{view_key}/apply/baseInfo")
7426
+ normalized = _normalize_data_list_base_info_schema(payload)
7427
+ self._form_cache[cache_key] = normalized
7428
+ return normalized
7429
+
7430
7430
  def _get_view_field_index(self, profile: str, context, view_key: str, *, force_refresh: bool) -> FieldIndex: # type: ignore[no-untyped-def]
7431
7431
  """执行内部辅助逻辑。"""
7432
7432
  return _build_field_index(self._get_view_form_schema(profile, context, view_key, force_refresh=force_refresh))
@@ -7469,6 +7469,80 @@ class RecordTools(ToolBase):
7469
7469
  force_refresh=force_refresh,
7470
7470
  )["index"]
7471
7471
 
7472
+ def _build_browse_read_scope(
7473
+ self,
7474
+ profile: str,
7475
+ context, # type: ignore[no-untyped-def]
7476
+ app_key: str,
7477
+ resolved_view: AccessibleViewRoute | None,
7478
+ *,
7479
+ force_refresh: bool,
7480
+ ) -> JSONObject:
7481
+ """Build the UI/table-view readable field scope from apply/baseInfo."""
7482
+ applicant_index: FieldIndex | None
7483
+ applicant_writable_field_ids: set[int]
7484
+ try:
7485
+ applicant_index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=force_refresh)
7486
+ except QingflowApiError as exc:
7487
+ if exc.backend_code != 40002:
7488
+ raise
7489
+ applicant_index = None
7490
+ applicant_writable_field_ids = set()
7491
+ else:
7492
+ applicant_writable_field_ids = {
7493
+ field.que_id
7494
+ for field in applicant_index.by_id.values()
7495
+ if bool(self._schema_write_hints(field)["writable"])
7496
+ }
7497
+
7498
+ if resolved_view is not None and resolved_view.kind == "custom" and resolved_view.view_selection is not None:
7499
+ schema = self._get_custom_view_browse_schema(
7500
+ profile,
7501
+ context,
7502
+ resolved_view.view_selection.view_key,
7503
+ force_refresh=force_refresh,
7504
+ )
7505
+ index = _build_top_level_field_index(schema)
7506
+ elif resolved_view is not None and resolved_view.kind == "system" and resolved_view.list_type is not None:
7507
+ schema = self._get_system_browse_base_info_schema(
7508
+ profile,
7509
+ context,
7510
+ app_key,
7511
+ list_type=resolved_view.list_type,
7512
+ force_refresh=force_refresh,
7513
+ )
7514
+ index = _build_top_level_field_index(schema)
7515
+ else:
7516
+ index = applicant_index or _build_top_level_field_index(
7517
+ self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
7518
+ )
7519
+
7520
+ if applicant_index is not None and index.by_id:
7521
+ enriched_fields = [
7522
+ _enrich_read_field_from_applicant(field, applicant_index.by_id.get(str(field.que_id)))
7523
+ for field in index.by_id.values()
7524
+ ]
7525
+ index = _build_top_level_field_index({"formQues": [[_form_field_to_question(field) for field in enriched_fields]]})
7526
+
7527
+ visible_question_ids = {field.que_id for field in index.by_id.values()}
7528
+ if applicant_index is None:
7529
+ writable_field_ids = {
7530
+ field.que_id
7531
+ for field in index.by_id.values()
7532
+ if bool(self._schema_write_hints(field)["writable"])
7533
+ }
7534
+ else:
7535
+ writable_field_ids = {
7536
+ field_id
7537
+ for field_id in visible_question_ids
7538
+ if field_id in applicant_writable_field_ids
7539
+ }
7540
+ return {
7541
+ "index": index,
7542
+ "writable_field_ids": writable_field_ids,
7543
+ "visible_question_ids": visible_question_ids,
7544
+ }
7545
+
7472
7546
  def _build_browse_write_scope(
7473
7547
  self,
7474
7548
  profile: str,
@@ -9538,6 +9612,44 @@ class RecordTools(ToolBase):
9538
9612
  break
9539
9613
  return fields
9540
9614
 
9615
+ def _resolve_record_access_columns(
9616
+ self,
9617
+ selectors: list[int],
9618
+ index: FieldIndex,
9619
+ *,
9620
+ view_route: AccessibleViewRoute,
9621
+ ) -> list[FormField]:
9622
+ """Resolve record_access columns against the selected view's baseInfo schema."""
9623
+ if not selectors:
9624
+ raise_tool_error(QingflowApiError.config_error("columns is required"))
9625
+ fields: list[FormField] = []
9626
+ seen: set[int] = set()
9627
+ for selector in selectors:
9628
+ try:
9629
+ field = self._resolve_field_selector(selector, index, location="record_access.columns")
9630
+ except RecordInputError as exc:
9631
+ if exc.error_code == "FIELD_NOT_FOUND":
9632
+ raise RecordInputError(
9633
+ message=(
9634
+ f"record_access column field_id '{selector}' is not in the selected view schema "
9635
+ f"({view_route.view_id})."
9636
+ ),
9637
+ error_code="FIELD_NOT_IN_VIEW_SCHEMA",
9638
+ fix_hint="Call record_browse_schema_get for this exact view_id and pass only field_id values from its fields[].",
9639
+ details={
9640
+ "location": "record_access.columns",
9641
+ "requested": selector,
9642
+ "view_id": view_route.view_id,
9643
+ "view_name": view_route.name,
9644
+ },
9645
+ ) from exc
9646
+ raise
9647
+ if field.que_id in seen:
9648
+ continue
9649
+ fields.append(field)
9650
+ seen.add(field.que_id)
9651
+ return fields
9652
+
9541
9653
  def _resolve_summary_preview_fields(
9542
9654
  self,
9543
9655
  selectors: list[str | int],
@@ -11295,6 +11407,38 @@ def _clone_form_field(field: FormField, *, readonly: bool | None = None) -> Form
11295
11407
  )
11296
11408
 
11297
11409
 
11410
+ def _enrich_read_field_from_applicant(field: FormField, applicant_field: FormField | None) -> FormField:
11411
+ if applicant_field is None:
11412
+ return field
11413
+ raw = dict(applicant_field.raw) if isinstance(applicant_field.raw, dict) else {}
11414
+ raw.update(dict(field.raw) if isinstance(field.raw, dict) else {})
11415
+ raw["queId"] = field.que_id
11416
+ raw["queTitle"] = field.que_title
11417
+ que_type = field.que_type if field.que_type is not None else applicant_field.que_type
11418
+ if que_type is not None:
11419
+ raw["queType"] = que_type
11420
+ readonly_source = field if any(key in field.raw for key in ("readonly", "beingReadonly", "canEdit")) else applicant_field
11421
+ enriched = FormField(
11422
+ que_id=field.que_id,
11423
+ que_title=field.que_title,
11424
+ que_type=que_type,
11425
+ required=field.required or applicant_field.required,
11426
+ readonly=readonly_source.readonly,
11427
+ system=field.system or applicant_field.system,
11428
+ options=list(field.options or applicant_field.options),
11429
+ aliases=[],
11430
+ target_app_key=field.target_app_key or applicant_field.target_app_key,
11431
+ target_app_name_hint=field.target_app_name_hint or applicant_field.target_app_name_hint,
11432
+ member_select_scope_type=field.member_select_scope_type if field.member_select_scope_type is not None else applicant_field.member_select_scope_type,
11433
+ member_select_scope=field.member_select_scope or applicant_field.member_select_scope,
11434
+ dept_select_scope_type=field.dept_select_scope_type if field.dept_select_scope_type is not None else applicant_field.dept_select_scope_type,
11435
+ dept_select_scope=field.dept_select_scope or applicant_field.dept_select_scope,
11436
+ raw=raw,
11437
+ )
11438
+ enriched.aliases = sorted(_field_alias_candidates(enriched))
11439
+ return enriched
11440
+
11441
+
11298
11442
  def _form_field_to_question(field: FormField) -> JSONObject:
11299
11443
  question = dict(field.raw) if isinstance(field.raw, dict) else {}
11300
11444
  question.setdefault("queId", field.que_id)