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

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.5
6
+ npm install @josephyan/qingflow-app-user-mcp@1.0.7
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-user-mcp@1.0.5 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@1.0.7 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.5",
3
+ "version": "1.0.7",
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.5"
7
+ version = "1.0.7"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -73,9 +73,9 @@ Use `record_access` to fetch detail rows into local CSV shards. It does not anal
73
73
  }
74
74
  ```
75
75
 
76
- Then run Python against every `files[].local_path`. CSV columns are stable: `record_id`, then `field_<field_id>`. Use `fields[]` metadata to map titles and types.
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
- - Never ask for `page`, `page_size`, `limit`, or `max_rows`; `record_access` owns paging internally.
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
79
  - If multiple CSV files are returned, read them all.
80
80
  - If `complete=false` or `safe_for_final_conclusion=false`, downgrade the answer and disclose the limitation.
81
81
  - `record_export_direct` is only for explicit export/download/Excel requests, not default analysis.
@@ -89,11 +89,12 @@ Examples of the right recovery question:
89
89
 
90
90
  ## Do not try to control paging manually
91
91
 
92
- `record_access` and `record_analyze` hide paging and scan budget on purpose.
92
+ `record_access` hides paging and follows the backend's native paging capability. `record_analyze` hides paging and scan budget on purpose.
93
93
 
94
94
  - Do not invent `page_size`
95
95
  - Do not invent `requested_pages`
96
96
  - Do not invent `scan_max_pages`
97
+ - Do not rename CSV columns before analysis; use `metadata_files.schema` / `metadata_files.readme` to interpret `field_<id>` columns.
97
98
  - Do not invent `auto_expand_pages`
98
99
  - Do not invent `max_rows`
99
100
 
@@ -28,9 +28,10 @@ Result reading order:
28
28
  1. `record_access.complete`
29
29
  2. `record_access.safe_for_final_conclusion`
30
30
  3. `record_access.files[].local_path`
31
- 4. Python outputs
32
- 5. `record_access.fields`
33
- 6. `record_access.warnings`
31
+ 4. `record_access.metadata_files.schema`
32
+ 5. Python outputs
33
+ 6. `record_access.fields`
34
+ 7. `record_access.warnings`
34
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
37
 
@@ -455,11 +455,13 @@ def _trim_record_access(payload: JSONObject) -> None:
455
455
  "app_key",
456
456
  "view_id",
457
457
  "format",
458
+ "local_dir",
458
459
  "row_count",
459
460
  "complete",
460
461
  "truncated",
461
462
  "safe_for_final_conclusion",
462
463
  "files",
464
+ "metadata_files",
463
465
  "fields",
464
466
  "warnings",
465
467
  "verification",
@@ -74,7 +74,7 @@ Inside `optional_fields`, any field with `may_become_required=true` is still wri
74
74
 
75
75
  Prefer `view_id` entries from `accessible_views` where `analysis_supported=true`.
76
76
 
77
- Use `record_access` to write local CSV shard files, then use Python to compute counts, rankings, ratios, trends, and final conclusions. `record_access` does not return bulk `items`; read `files[].local_path`.
77
+ Use `record_access` to write local CSV shard files, then use Python to compute counts, rankings, ratios, trends, and final conclusions. `record_access` does not return bulk `items`; read `files[].local_path`. Use `fields[]` and the same-directory `metadata_files.schema` / `metadata_files.readme` to map stable `field_<id>` CSV columns back to titles and types.
78
78
  Use `chart_get` only when the user provides a report URL / chart_id or explicitly asks to read an existing report. Do not use QingBI as the default analysis route.
79
79
 
80
80
  Use this data-access DSL shape:
@@ -32,9 +32,8 @@ from .directory_tools import _directory_has_more, _directory_items
32
32
 
33
33
  DEFAULT_QUERY_PAGE_SIZE = 50
34
34
  DEFAULT_LIST_PAGE_SIZE = 200
35
+ BACKEND_RECORD_ACCESS_PAGE_SIZE = 1000
35
36
  DEFAULT_RECORD_ACCESS_SHARD_ROWS = 5000
36
- DEFAULT_RECORD_ACCESS_HARD_ROWS = 50000
37
- DEFAULT_RECORD_ACCESS_TIMEOUT_SECONDS = 60
38
37
  DEFAULT_ANALYSIS_PAGE_SIZE = 1000
39
38
  DEFAULT_SCAN_MAX_PAGES = 10
40
39
  DEFAULT_ANALYSIS_SCAN_MAX_PAGES = 100
@@ -1870,7 +1869,6 @@ class RecordTools(ToolBase):
1870
1869
  primary_search_que_ids = primary_search_que_ids or None
1871
1870
  match_rules = self._resolve_match_rules(context, filters, index)
1872
1871
  sort_rules = self._resolve_sorts(sorts, index)
1873
- dept_member_cache: dict[int, set[int]] = {}
1874
1872
  used_list_type: int | None = None
1875
1873
  fallback_list_types = (
1876
1874
  [view_route.list_type if view_route.list_type is not None else DEFAULT_RECORD_LIST_TYPE]
@@ -1934,17 +1932,9 @@ class RecordTools(ToolBase):
1934
1932
  current_page = 1
1935
1933
  has_more = False
1936
1934
  reported_total: int | None = None
1937
- limit_reached = False
1938
- timeout_reached = False
1939
- started_at = time.perf_counter()
1935
+ created_at = datetime.now(UTC).isoformat()
1940
1936
  try:
1941
1937
  while True:
1942
- if row_count >= DEFAULT_RECORD_ACCESS_HARD_ROWS:
1943
- limit_reached = True
1944
- break
1945
- if time.perf_counter() - started_at >= DEFAULT_RECORD_ACCESS_TIMEOUT_SECONDS:
1946
- timeout_reached = True
1947
- break
1948
1938
  if used_list_type is None:
1949
1939
  last_error: QingflowApiError | None = None
1950
1940
  page: JSONObject | None = None
@@ -1955,7 +1945,7 @@ class RecordTools(ToolBase):
1955
1945
  app_key=app_key,
1956
1946
  view_selection=view_selection,
1957
1947
  page_num=current_page,
1958
- page_size=DEFAULT_LIST_PAGE_SIZE,
1948
+ page_size=BACKEND_RECORD_ACCESS_PAGE_SIZE,
1959
1949
  query_key=None,
1960
1950
  match_rules=match_rules,
1961
1951
  sorts=sort_rules,
@@ -1982,7 +1972,7 @@ class RecordTools(ToolBase):
1982
1972
  app_key=app_key,
1983
1973
  view_selection=view_selection,
1984
1974
  page_num=current_page,
1985
- page_size=DEFAULT_LIST_PAGE_SIZE,
1975
+ page_size=BACKEND_RECORD_ACCESS_PAGE_SIZE,
1986
1976
  query_key=None,
1987
1977
  match_rules=match_rules,
1988
1978
  sorts=sort_rules,
@@ -1992,25 +1982,15 @@ class RecordTools(ToolBase):
1992
1982
  page_rows = page.get("list")
1993
1983
  items = page_rows if isinstance(page_rows, list) else []
1994
1984
  if reported_total is None:
1995
- reported_total = _effective_total(page, DEFAULT_LIST_PAGE_SIZE)
1996
- has_more = _page_has_more(page, current_page, DEFAULT_LIST_PAGE_SIZE, len(items))
1985
+ reported_total = _effective_total(page, BACKEND_RECORD_ACCESS_PAGE_SIZE)
1986
+ has_more = _page_has_more(page, current_page, BACKEND_RECORD_ACCESS_PAGE_SIZE, len(items))
1997
1987
  page_apply_order: list[int] = []
1998
1988
  page_answer_map: dict[int, list[JSONValue]] = {}
1999
1989
  for item in items:
2000
- if row_count + len(page_apply_order) >= DEFAULT_RECORD_ACCESS_HARD_ROWS:
2001
- limit_reached = True
2002
- break
2003
1990
  if not isinstance(item, dict):
2004
1991
  continue
2005
1992
  answers = item.get("answers")
2006
1993
  answer_list = answers if isinstance(answers, list) else []
2007
- if not self._matches_view_selection(
2008
- context,
2009
- answer_list,
2010
- view_selection=view_selection,
2011
- dept_member_cache=dept_member_cache,
2012
- ):
2013
- continue
2014
1994
  apply_id = _coerce_count(item.get("applyId")) or _coerce_count(item.get("id"))
2015
1995
  if apply_id is None:
2016
1996
  continue
@@ -2023,7 +2003,7 @@ class RecordTools(ToolBase):
2023
2003
  app_key=app_key,
2024
2004
  view_selection=view_selection,
2025
2005
  page_num=current_page,
2026
- page_size=DEFAULT_LIST_PAGE_SIZE,
2006
+ page_size=BACKEND_RECORD_ACCESS_PAGE_SIZE,
2027
2007
  query_key=None,
2028
2008
  match_rules=match_rules,
2029
2009
  sorts=sort_rules,
@@ -2045,11 +2025,8 @@ class RecordTools(ToolBase):
2045
2025
  cast(list[JSONValue], extra_answer_list),
2046
2026
  )
2047
2027
  for apply_id in page_apply_order:
2048
- if row_count >= DEFAULT_RECORD_ACCESS_HARD_ROWS:
2049
- limit_reached = True
2050
- break
2051
2028
  write_record(apply_id, page_answer_map.get(apply_id, []))
2052
- if limit_reached or not has_more:
2029
+ if not has_more:
2053
2030
  break
2054
2031
  current_page += 1
2055
2032
  finally:
@@ -2069,24 +2046,29 @@ class RecordTools(ToolBase):
2069
2046
  ),
2070
2047
  }
2071
2048
  )
2072
- if limit_reached:
2073
- warnings.append(
2074
- {
2075
- "code": "RECORD_ACCESS_INTERNAL_LIMIT_REACHED",
2076
- "message": "record_access reached its internal row limit before all pages were fetched.",
2077
- }
2078
- )
2079
- if timeout_reached:
2080
- warnings.append(
2081
- {
2082
- "code": "RECORD_ACCESS_TIMEOUT",
2083
- "message": "record_access reached its internal time limit before all pages were fetched.",
2084
- }
2085
- )
2086
- complete = not has_more and not limit_reached and not timeout_reached
2049
+ complete = not has_more
2087
2050
  safe_for_final_conclusion = complete and not any(
2088
2051
  warning.get("code") == "CUSTOM_VIEW_FILTER_UNVERIFIED" for warning in warnings
2089
2052
  )
2053
+ fields_payload = [_record_access_field_payload(field) for field in selected_fields]
2054
+ verification_payload: JSONObject = {
2055
+ **_view_filter_verification_payload(view_route),
2056
+ "reported_total": reported_total,
2057
+ "list_type_used": used_list_type,
2058
+ }
2059
+ metadata_files = _write_record_access_metadata_files(
2060
+ run_dir=run_dir,
2061
+ created_at=created_at,
2062
+ app_key=app_key,
2063
+ view_route=view_route,
2064
+ row_count=row_count,
2065
+ complete=complete,
2066
+ safe_for_final_conclusion=safe_for_final_conclusion,
2067
+ files=files,
2068
+ fields=fields_payload,
2069
+ warnings=warnings,
2070
+ verification=verification_payload,
2071
+ )
2090
2072
  return {
2091
2073
  "profile": profile,
2092
2074
  "ws_id": session_profile.selected_ws_id,
@@ -2095,18 +2077,16 @@ class RecordTools(ToolBase):
2095
2077
  "app_key": app_key,
2096
2078
  "view_id": view_route.view_id,
2097
2079
  "format": "csv",
2080
+ "local_dir": str(run_dir),
2098
2081
  "row_count": row_count,
2099
2082
  "complete": complete,
2100
2083
  "truncated": not complete,
2101
2084
  "safe_for_final_conclusion": safe_for_final_conclusion,
2102
2085
  "files": files,
2103
- "fields": [_record_access_field_payload(field) for field in selected_fields],
2086
+ "metadata_files": metadata_files,
2087
+ "fields": fields_payload,
2104
2088
  "warnings": warnings,
2105
- "verification": {
2106
- **_view_filter_verification_payload(view_route),
2107
- "reported_total": reported_total,
2108
- "list_type_used": used_list_type,
2109
- },
2089
+ "verification": verification_payload,
2110
2090
  "request_route": self._request_route_payload(context),
2111
2091
  }
2112
2092
 
@@ -5594,9 +5574,8 @@ class RecordTools(ToolBase):
5594
5574
  source_pages: list[int] = []
5595
5575
  result_amount: int | None = None
5596
5576
  has_more = False
5597
- dept_member_cache: dict[int, set[int]] = {}
5598
5577
  view_selection = resolved_view.view_selection
5599
- local_filtering = bool(filters) or bool(view_selection is not None and view_selection.conditions)
5578
+ local_filtering = bool(filters)
5600
5579
  group_stats: dict[tuple[tuple[str, object], ...], JSONObject] = {}
5601
5580
  overall_metrics = self._initialize_metric_states(metrics)
5602
5581
  matched_rows = 0
@@ -5640,13 +5619,6 @@ class RecordTools(ToolBase):
5640
5619
  continue
5641
5620
  answers = item.get("answers")
5642
5621
  answer_list = answers if isinstance(answers, list) else []
5643
- if not self._matches_view_selection(
5644
- context,
5645
- answer_list,
5646
- view_selection=view_selection,
5647
- dept_member_cache=dept_member_cache,
5648
- ):
5649
- continue
5650
5622
  if not self._matches_analyze_filters(answer_list, filters):
5651
5623
  continue
5652
5624
  matched_rows += 1
@@ -6732,7 +6704,6 @@ class RecordTools(ToolBase):
6732
6704
  if view_selection is not None
6733
6705
  else self._get_field_index(profile, context, app_key, force_refresh=False)
6734
6706
  )
6735
- dept_member_cache: dict[int, set[int]] = {}
6736
6707
  result = self._search_page(
6737
6708
  context,
6738
6709
  app_key=app_key,
@@ -6747,17 +6718,7 @@ class RecordTools(ToolBase):
6747
6718
  )
6748
6719
  rows = result.get("list")
6749
6720
  raw_rows = rows if isinstance(rows, list) else []
6750
- filtered_rows = [
6751
- item
6752
- for item in raw_rows
6753
- if isinstance(item, dict)
6754
- and self._matches_view_selection(
6755
- context,
6756
- item.get("answers") if isinstance(item.get("answers"), list) else [],
6757
- view_selection=view_selection,
6758
- dept_member_cache=dept_member_cache,
6759
- )
6760
- ]
6721
+ filtered_rows = [item for item in raw_rows if isinstance(item, dict)]
6761
6722
  if isinstance(rows, list):
6762
6723
  result = dict(result)
6763
6724
  result["list"] = filtered_rows
@@ -7007,7 +6968,6 @@ class RecordTools(ToolBase):
7007
6968
  def runner(session_profile, context):
7008
6969
  index = self._get_field_index(profile, context, app_key, force_refresh=False)
7009
6970
  view_selection = self._resolve_view_selection(profile, context, app_key, view_key=view_key, view_name=view_name)
7010
- dept_member_cache: dict[int, set[int]] = {}
7011
6971
  resolved_column_cap = _bounded_column_limit(
7012
6972
  max_columns,
7013
6973
  default_limit=MAX_LIST_COLUMN_LIMIT,
@@ -7113,13 +7073,6 @@ class RecordTools(ToolBase):
7113
7073
  continue
7114
7074
  answers = item.get("answers")
7115
7075
  answer_list = answers if isinstance(answers, list) else []
7116
- if not self._matches_view_selection(
7117
- context,
7118
- answer_list,
7119
- view_selection=view_selection,
7120
- dept_member_cache=dept_member_cache,
7121
- ):
7122
- continue
7123
7076
  matched_records += 1
7124
7077
  apply_id = _coerce_count(item.get("applyId")) or _coerce_count(item.get("id"))
7125
7078
  row = _build_flat_row(answer_list, selected_fields_from_primary, apply_id=apply_id)
@@ -11568,6 +11521,133 @@ def _record_access_field_payload(field: FormField) -> JSONObject:
11568
11521
  }
11569
11522
 
11570
11523
 
11524
+ def _write_record_access_metadata_files(
11525
+ *,
11526
+ run_dir: Path,
11527
+ created_at: str,
11528
+ app_key: str,
11529
+ view_route: AccessibleViewRoute,
11530
+ row_count: int,
11531
+ complete: bool,
11532
+ safe_for_final_conclusion: bool,
11533
+ files: list[JSONObject],
11534
+ fields: list[JSONObject],
11535
+ warnings: list[JSONObject],
11536
+ verification: JSONObject,
11537
+ ) -> JSONObject:
11538
+ schema_path = run_dir / "schema.json"
11539
+ readme_path = run_dir / "README.md"
11540
+ schema_payload: JSONObject = {
11541
+ "created_at": created_at,
11542
+ "app_key": app_key,
11543
+ "view_id": view_route.view_id,
11544
+ "view_name": view_route.name,
11545
+ "view_type": view_route.view_type,
11546
+ "format": "csv",
11547
+ "row_count": row_count,
11548
+ "complete": complete,
11549
+ "truncated": not complete,
11550
+ "safe_for_final_conclusion": safe_for_final_conclusion,
11551
+ "files": files,
11552
+ "fields": fields,
11553
+ "warnings": warnings,
11554
+ "verification": verification,
11555
+ "csv_columns": ["record_id"] + [str(field["column_name"]) for field in fields if field.get("column_name")],
11556
+ }
11557
+ schema_path.write_text(json.dumps(schema_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
11558
+ readme_path.write_text(
11559
+ _record_access_readme(
11560
+ created_at=created_at,
11561
+ app_key=app_key,
11562
+ view_route=view_route,
11563
+ row_count=row_count,
11564
+ complete=complete,
11565
+ safe_for_final_conclusion=safe_for_final_conclusion,
11566
+ files=files,
11567
+ fields=fields,
11568
+ warnings=warnings,
11569
+ ),
11570
+ encoding="utf-8",
11571
+ )
11572
+ return {
11573
+ "schema": str(schema_path),
11574
+ "readme": str(readme_path),
11575
+ }
11576
+
11577
+
11578
+ def _record_access_readme(
11579
+ *,
11580
+ created_at: str,
11581
+ app_key: str,
11582
+ view_route: AccessibleViewRoute,
11583
+ row_count: int,
11584
+ complete: bool,
11585
+ safe_for_final_conclusion: bool,
11586
+ files: list[JSONObject],
11587
+ fields: list[JSONObject],
11588
+ warnings: list[JSONObject],
11589
+ ) -> str:
11590
+ lines = [
11591
+ "# Qingflow Record Access",
11592
+ "",
11593
+ f"- Created at: {created_at}",
11594
+ f"- App key: `{app_key}`",
11595
+ f"- View: {view_route.name} (`{view_route.view_id}`)",
11596
+ f"- Format: CSV",
11597
+ f"- Rows: {row_count}",
11598
+ f"- Complete: {str(complete).lower()}",
11599
+ f"- Safe for final conclusion: {str(safe_for_final_conclusion).lower()}",
11600
+ "",
11601
+ "## Data Files",
11602
+ "",
11603
+ ]
11604
+ if files:
11605
+ for file_info in files:
11606
+ local_path = _normalize_optional_text(file_info.get("local_path")) or ""
11607
+ part = file_info.get("part")
11608
+ file_rows = file_info.get("row_count")
11609
+ lines.append(f"- `{Path(local_path).name}`: part {part}, {file_rows} rows")
11610
+ else:
11611
+ lines.append("- No CSV shard files were written because the query returned 0 rows.")
11612
+ lines.extend(
11613
+ [
11614
+ "",
11615
+ "## Columns",
11616
+ "",
11617
+ "| CSV column | Field ID | Title | Type |",
11618
+ "|---|---:|---|---|",
11619
+ "| `record_id` | - | Record ID | record_id |",
11620
+ ]
11621
+ )
11622
+ for field in fields:
11623
+ column_name = _markdown_escape(str(field.get("column_name", "")))
11624
+ field_id = field.get("field_id", "")
11625
+ title = _markdown_escape(str(field.get("title", "")))
11626
+ field_type = _markdown_escape(str(field.get("type", "")))
11627
+ lines.append(f"| `{column_name}` | {field_id} | {title} | {field_type} |")
11628
+ if warnings:
11629
+ lines.extend(["", "## Warnings", ""])
11630
+ for warning in warnings:
11631
+ code = _normalize_optional_text(warning.get("code")) or "WARNING"
11632
+ message = _normalize_optional_text(warning.get("message")) or ""
11633
+ lines.append(f"- `{code}`: {message}")
11634
+ lines.extend(
11635
+ [
11636
+ "",
11637
+ "## Usage Notes",
11638
+ "",
11639
+ "- Use `records-*.csv` for Python or pandas processing; column names are stable and field-id based.",
11640
+ "- Use `schema.json` or this README to map `field_<field_id>` columns back to Qingflow field titles and types.",
11641
+ "",
11642
+ ]
11643
+ )
11644
+ return "\n".join(lines)
11645
+
11646
+
11647
+ def _markdown_escape(value: str) -> str:
11648
+ return value.replace("\\", "\\\\").replace("|", "\\|").replace("\n", " ")
11649
+
11650
+
11571
11651
  def _record_access_field_type(field: FormField) -> str:
11572
11652
  if field.que_type in DATE_QUE_TYPES:
11573
11653
  return "datetime"