@josephyan/qingflow-cli 0.2.0-beta.999 → 1.0.6

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.
Files changed (36) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/qingflow_mcp/__init__.py +1 -1
  5. package/src/qingflow_mcp/backend_client.py +109 -0
  6. package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
  7. package/src/qingflow_mcp/builder_facade/models.py +44 -5
  8. package/src/qingflow_mcp/builder_facade/service.py +21 -8
  9. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  10. package/src/qingflow_mcp/cli/commands/app.py +47 -1
  11. package/src/qingflow_mcp/cli/commands/builder.py +7 -0
  12. package/src/qingflow_mcp/cli/commands/exports.py +111 -0
  13. package/src/qingflow_mcp/cli/commands/record.py +20 -0
  14. package/src/qingflow_mcp/cli/commands/task.py +644 -22
  15. package/src/qingflow_mcp/cli/commands/workspace.py +49 -50
  16. package/src/qingflow_mcp/cli/context.py +3 -0
  17. package/src/qingflow_mcp/cli/formatters.py +139 -4
  18. package/src/qingflow_mcp/cli/interaction.py +72 -0
  19. package/src/qingflow_mcp/cli/main.py +2 -0
  20. package/src/qingflow_mcp/cli/terminal_ui.py +55 -9
  21. package/src/qingflow_mcp/errors.py +2 -2
  22. package/src/qingflow_mcp/export_store.py +14 -0
  23. package/src/qingflow_mcp/public_surface.py +6 -0
  24. package/src/qingflow_mcp/response_trim.py +40 -1
  25. package/src/qingflow_mcp/server.py +22 -0
  26. package/src/qingflow_mcp/server_app_builder.py +4 -0
  27. package/src/qingflow_mcp/server_app_user.py +104 -8
  28. package/src/qingflow_mcp/session_store.py +57 -6
  29. package/src/qingflow_mcp/tools/ai_builder_tools.py +59 -16
  30. package/src/qingflow_mcp/tools/auth_tools.py +26 -0
  31. package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
  32. package/src/qingflow_mcp/tools/export_tools.py +1565 -0
  33. package/src/qingflow_mcp/tools/import_tools.py +42 -2
  34. package/src/qingflow_mcp/tools/record_tools.py +515 -45
  35. package/src/qingflow_mcp/tools/resource_read_tools.py +40 -1
  36. package/src/qingflow_mcp/tools/task_context_tools.py +26 -8
@@ -1,17 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import csv
3
4
  import json
5
+ import os
4
6
  import re
5
7
  import time
6
8
  from copy import deepcopy
7
9
  from dataclasses import dataclass
8
10
  from datetime import UTC, datetime
9
11
  from decimal import Decimal, InvalidOperation
12
+ from pathlib import Path
10
13
  from typing import Any, cast
14
+ from uuid import uuid4
11
15
 
12
16
  from mcp.server.fastmcp import FastMCP
13
17
 
14
- from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE
18
+ from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE, get_mcp_home
15
19
  from ..errors import QingflowApiError, raise_tool_error
16
20
  from ..id_utils import normalize_positive_id_int, stringify_backend_id
17
21
  from ..json_types import JSONObject, JSONScalar, JSONValue
@@ -28,6 +32,8 @@ from .directory_tools import _directory_has_more, _directory_items
28
32
 
29
33
  DEFAULT_QUERY_PAGE_SIZE = 50
30
34
  DEFAULT_LIST_PAGE_SIZE = 200
35
+ BACKEND_RECORD_ACCESS_PAGE_SIZE = 1000
36
+ DEFAULT_RECORD_ACCESS_SHARD_ROWS = 5000
31
37
  DEFAULT_ANALYSIS_PAGE_SIZE = 1000
32
38
  DEFAULT_SCAN_MAX_PAGES = 10
33
39
  DEFAULT_ANALYSIS_SCAN_MAX_PAGES = 100
@@ -49,6 +55,7 @@ LOOKUP_RELATION_FILTER_PAGE_SIZE = 20
49
55
  MEMBER_QUE_TYPES = {5}
50
56
  DEPARTMENT_QUE_TYPES = {22}
51
57
  DATE_QUE_TYPES = {4}
58
+ NUMBER_QUE_TYPES = {8}
52
59
  ADDRESS_QUE_TYPES = {21}
53
60
  SINGLE_SELECT_QUE_TYPES = {10, 11}
54
61
  MULTI_SELECT_QUE_TYPES = {12, 15}
@@ -337,7 +344,8 @@ class RecordTools(ToolBase):
337
344
 
338
345
  @mcp.tool(
339
346
  description=(
340
- "Run schema-first analytics on a Qingflow app using a restricted DSL. "
347
+ "Run lightweight schema-first analytics on a Qingflow app using a restricted DSL. "
348
+ "This is not the default analysis path; prefer record_access plus Python for final conclusions. "
341
349
  "Use record_browse_schema_get first, then let the model build a DSL with field_id references only. "
342
350
  "dimensions=[] means whole-table summary; dimensions!=[] means grouped analysis. "
343
351
  "This route hides paging and scan-budget controls from callers."
@@ -404,6 +412,29 @@ class RecordTools(ToolBase):
404
412
  output_profile=output_profile,
405
413
  )
406
414
 
415
+ @mcp.tool(
416
+ description=(
417
+ "Access Qingflow records for analysis by writing local CSV shard files. "
418
+ "Use app_get -> record_browse_schema_get first, then pass field_id-only columns, where, and order_by. "
419
+ "This tool hides pagination and row limits from the caller and returns file metadata instead of record items."
420
+ )
421
+ )
422
+ def record_access(
423
+ app_key: str = "",
424
+ view_id: str = "",
425
+ columns: list[JSONObject | int] | None = None,
426
+ where: list[JSONObject] | None = None,
427
+ order_by: list[JSONObject] | None = None,
428
+ ) -> JSONObject:
429
+ return self.record_access(
430
+ profile=DEFAULT_PROFILE,
431
+ app_key=app_key,
432
+ view_id=view_id,
433
+ columns=columns or [],
434
+ where=where or [],
435
+ order_by=order_by or [],
436
+ )
437
+
407
438
  @mcp.tool(description="Read one Qingflow record by record_id. Use record_browse_schema_get first if columns are ambiguous.")
408
439
  def record_get(
409
440
  profile: str = DEFAULT_PROFILE,
@@ -1759,6 +1790,290 @@ class RecordTools(ToolBase):
1759
1790
  }
1760
1791
  return response
1761
1792
 
1793
+ @tool_cn_name("记录访问")
1794
+ def record_access(
1795
+ self,
1796
+ *,
1797
+ profile: str,
1798
+ app_key: str,
1799
+ view_id: str,
1800
+ columns: list[JSONObject | int],
1801
+ where: list[JSONObject],
1802
+ order_by: list[JSONObject],
1803
+ ) -> JSONObject:
1804
+ """Fetch records across pages and write stable CSV shards for local analysis."""
1805
+ if not app_key:
1806
+ raise_tool_error(QingflowApiError.config_error("app_key is required"))
1807
+ if not (view_id or "").strip():
1808
+ raise_tool_error(
1809
+ QingflowApiError.config_error(
1810
+ "record_access requires view_id. Call app_get first and pass accessible_views[].view_id."
1811
+ )
1812
+ )
1813
+ legacy_warnings = _detect_record_list_legacy_warnings(columns=columns, where=where, order_by=order_by)
1814
+ normalized_columns = _normalize_public_column_selectors(columns)
1815
+ if not normalized_columns:
1816
+ raise_tool_error(
1817
+ QingflowApiError.config_error(
1818
+ "columns is required. Call record_browse_schema_get first and pass field_id-only columns."
1819
+ )
1820
+ )
1821
+ view_route, compatibility_warnings = self._resolve_accessible_view_route_for_public(
1822
+ profile=profile,
1823
+ app_key=app_key,
1824
+ view_id=view_id,
1825
+ list_type=None,
1826
+ view_key=None,
1827
+ view_name=None,
1828
+ allow_default=False,
1829
+ )
1830
+ if not _view_type_supports_analysis(view_route.view_type):
1831
+ raise_tool_error(
1832
+ QingflowApiError(
1833
+ category="not_supported",
1834
+ message=(
1835
+ f"record_access does not support view '{view_route.name}' "
1836
+ f"because its type is {view_route.view_type}."
1837
+ ),
1838
+ details={
1839
+ "error_code": "VIEW_ACCESS_UNSUPPORTED",
1840
+ "view_id": view_route.view_id,
1841
+ "view_name": view_route.name,
1842
+ "view_type": view_route.view_type,
1843
+ "fix_hint": "Choose a system view or a custom table-style view from app_get.accessible_views.",
1844
+ },
1845
+ )
1846
+ )
1847
+ filters = self._normalize_record_list_where(where)
1848
+ sorts = self._normalize_record_list_order_by(order_by)
1849
+
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(
1853
+ normalized_columns,
1854
+ index,
1855
+ max_columns=len(normalized_columns),
1856
+ default_limit=MAX_LIST_COLUMN_LIMIT,
1857
+ )
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
+ 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
+ match_rules = self._resolve_match_rules(context, filters, index)
1871
+ sort_rules = self._resolve_sorts(sorts, index)
1872
+ used_list_type: int | None = None
1873
+ fallback_list_types = (
1874
+ [view_route.list_type if view_route.list_type is not None else DEFAULT_RECORD_LIST_TYPE]
1875
+ if view_selection is not None or view_route.list_type is not None
1876
+ else [DEFAULT_RECORD_LIST_TYPE, 14, 1, 2, 12]
1877
+ )
1878
+
1879
+ run_dir = _record_access_run_dir()
1880
+ run_dir.mkdir(parents=True, exist_ok=True)
1881
+ header = ["record_id"] + [f"field_{field.que_id}" for field in selected_fields]
1882
+ files: list[JSONObject] = []
1883
+ shard_part = 0
1884
+ shard_rows = 0
1885
+ row_count = 0
1886
+ file_handle: Any | None = None
1887
+ writer: Any | None = None
1888
+
1889
+ def close_shard() -> None:
1890
+ nonlocal file_handle, writer, shard_rows
1891
+ if file_handle is None:
1892
+ return
1893
+ path = Path(file_handle.name)
1894
+ file_handle.close()
1895
+ files.append(
1896
+ {
1897
+ "local_path": str(path),
1898
+ "row_count": shard_rows,
1899
+ "part": len(files) + 1,
1900
+ }
1901
+ )
1902
+ file_handle = None
1903
+ writer = None
1904
+ shard_rows = 0
1905
+
1906
+ def ensure_writer() -> Any:
1907
+ nonlocal file_handle, writer, shard_part
1908
+ if writer is not None:
1909
+ return writer
1910
+ shard_part += 1
1911
+ path = run_dir / f"records-{shard_part:04d}.csv"
1912
+ file_handle = path.open("w", newline="", encoding="utf-8-sig")
1913
+ writer = csv.writer(file_handle)
1914
+ writer.writerow(header)
1915
+ return writer
1916
+
1917
+ def write_record(apply_id: int | None, answer_list: list[JSONValue]) -> None:
1918
+ nonlocal row_count, shard_rows
1919
+ if apply_id is None:
1920
+ return
1921
+ if shard_rows >= DEFAULT_RECORD_ACCESS_SHARD_ROWS:
1922
+ close_shard()
1923
+ csv_writer = ensure_writer()
1924
+ values: list[str] = [_public_record_id_text(apply_id) or ""]
1925
+ for field in selected_fields:
1926
+ answer = _find_answer_for_field(answer_list, field)
1927
+ values.append(_record_access_csv_cell(_normalize_answer_field_value_for_output(answer, field)))
1928
+ csv_writer.writerow(values)
1929
+ row_count += 1
1930
+ shard_rows += 1
1931
+
1932
+ current_page = 1
1933
+ has_more = False
1934
+ reported_total: int | None = None
1935
+ try:
1936
+ while True:
1937
+ if used_list_type is None:
1938
+ last_error: QingflowApiError | None = None
1939
+ page: JSONObject | None = None
1940
+ for candidate_list_type in fallback_list_types:
1941
+ try:
1942
+ page = self._search_page(
1943
+ context,
1944
+ app_key=app_key,
1945
+ view_selection=view_selection,
1946
+ page_num=current_page,
1947
+ page_size=BACKEND_RECORD_ACCESS_PAGE_SIZE,
1948
+ query_key=None,
1949
+ match_rules=match_rules,
1950
+ sorts=sort_rules,
1951
+ search_que_ids=primary_search_que_ids,
1952
+ list_type=candidate_list_type,
1953
+ )
1954
+ used_list_type = None if view_selection is not None else candidate_list_type
1955
+ break
1956
+ except QingflowApiError as exc:
1957
+ last_error = exc
1958
+ if (
1959
+ self._should_retry_list_type_fallback(exc)
1960
+ and candidate_list_type != fallback_list_types[-1]
1961
+ ):
1962
+ continue
1963
+ raise
1964
+ if page is None:
1965
+ if last_error is not None:
1966
+ raise last_error
1967
+ raise_tool_error(QingflowApiError.config_error("record_access failed: no accessible listType"))
1968
+ else:
1969
+ page = self._search_page(
1970
+ context,
1971
+ app_key=app_key,
1972
+ view_selection=view_selection,
1973
+ page_num=current_page,
1974
+ page_size=BACKEND_RECORD_ACCESS_PAGE_SIZE,
1975
+ query_key=None,
1976
+ match_rules=match_rules,
1977
+ sorts=sort_rules,
1978
+ search_que_ids=primary_search_que_ids,
1979
+ list_type=used_list_type,
1980
+ )
1981
+ page_rows = page.get("list")
1982
+ items = page_rows if isinstance(page_rows, list) else []
1983
+ if reported_total is None:
1984
+ reported_total = _effective_total(page, BACKEND_RECORD_ACCESS_PAGE_SIZE)
1985
+ has_more = _page_has_more(page, current_page, BACKEND_RECORD_ACCESS_PAGE_SIZE, len(items))
1986
+ page_apply_order: list[int] = []
1987
+ page_answer_map: dict[int, list[JSONValue]] = {}
1988
+ for item in items:
1989
+ if not isinstance(item, dict):
1990
+ continue
1991
+ answers = item.get("answers")
1992
+ answer_list = answers if isinstance(answers, list) else []
1993
+ apply_id = _coerce_count(item.get("applyId")) or _coerce_count(item.get("id"))
1994
+ if apply_id is None:
1995
+ continue
1996
+ page_apply_order.append(apply_id)
1997
+ page_answer_map[apply_id] = cast(list[JSONValue], answer_list)
1998
+ if page_apply_order and remaining_field_batches:
1999
+ for batch in remaining_field_batches:
2000
+ extra_page = self._search_page(
2001
+ context,
2002
+ app_key=app_key,
2003
+ view_selection=view_selection,
2004
+ page_num=current_page,
2005
+ page_size=BACKEND_RECORD_ACCESS_PAGE_SIZE,
2006
+ query_key=None,
2007
+ match_rules=match_rules,
2008
+ sorts=sort_rules,
2009
+ search_que_ids=[field.que_id for field in batch],
2010
+ list_type=used_list_type or DEFAULT_RECORD_LIST_TYPE,
2011
+ )
2012
+ extra_rows = extra_page.get("list")
2013
+ extra_items = extra_rows if isinstance(extra_rows, list) else []
2014
+ for extra_item in extra_items:
2015
+ if not isinstance(extra_item, dict):
2016
+ continue
2017
+ apply_id = _coerce_count(extra_item.get("applyId")) or _coerce_count(extra_item.get("id"))
2018
+ if apply_id is None or apply_id not in page_answer_map:
2019
+ continue
2020
+ extra_answers = extra_item.get("answers")
2021
+ extra_answer_list = extra_answers if isinstance(extra_answers, list) else []
2022
+ page_answer_map[apply_id] = _merge_answer_lists_by_field_id(
2023
+ page_answer_map.get(apply_id, []),
2024
+ cast(list[JSONValue], extra_answer_list),
2025
+ )
2026
+ for apply_id in page_apply_order:
2027
+ write_record(apply_id, page_answer_map.get(apply_id, []))
2028
+ if not has_more:
2029
+ break
2030
+ current_page += 1
2031
+ finally:
2032
+ close_shard()
2033
+
2034
+ warnings: list[JSONObject] = []
2035
+ warnings.extend(legacy_warnings)
2036
+ warnings.extend(compatibility_warnings)
2037
+ warnings.extend(_view_filter_trust_warnings(view_route))
2038
+ if used_list_type is not None and used_list_type != DEFAULT_RECORD_LIST_TYPE:
2039
+ warnings.append(
2040
+ {
2041
+ "code": "LIST_TYPE_FALLBACK",
2042
+ "message": (
2043
+ f"record_access not accessible via listType={DEFAULT_RECORD_LIST_TYPE}; "
2044
+ f"fell back to listType={used_list_type} ({get_record_list_type_label(used_list_type)})."
2045
+ ),
2046
+ }
2047
+ )
2048
+ complete = not has_more
2049
+ safe_for_final_conclusion = complete and not any(
2050
+ warning.get("code") == "CUSTOM_VIEW_FILTER_UNVERIFIED" for warning in warnings
2051
+ )
2052
+ return {
2053
+ "profile": profile,
2054
+ "ws_id": session_profile.selected_ws_id,
2055
+ "ok": True,
2056
+ "status": "success" if complete else "partial",
2057
+ "app_key": app_key,
2058
+ "view_id": view_route.view_id,
2059
+ "format": "csv",
2060
+ "row_count": row_count,
2061
+ "complete": complete,
2062
+ "truncated": not complete,
2063
+ "safe_for_final_conclusion": safe_for_final_conclusion,
2064
+ "files": files,
2065
+ "fields": [_record_access_field_payload(field) for field in selected_fields],
2066
+ "warnings": warnings,
2067
+ "verification": {
2068
+ **_view_filter_verification_payload(view_route),
2069
+ "reported_total": reported_total,
2070
+ "list_type_used": used_list_type,
2071
+ },
2072
+ "request_route": self._request_route_payload(context),
2073
+ }
2074
+
2075
+ return self._run_record_tool(profile, runner)
2076
+
1762
2077
  @tool_cn_name("记录详情")
1763
2078
  def record_get_public(
1764
2079
  self,
@@ -2920,12 +3235,14 @@ class RecordTools(ToolBase):
2920
3235
  payload["category"] = error.category
2921
3236
  if error.backend_code is not None:
2922
3237
  payload["backend_code"] = error.backend_code
3238
+ if error.request_id is not None:
3239
+ payload["request_id"] = error.request_id
2923
3240
  if error.http_status is not None:
2924
3241
  payload["http_status"] = error.http_status
2925
3242
  return payload
2926
3243
 
2927
3244
  def _is_record_context_route_miss(self, error: QingflowApiError) -> bool:
2928
- if error.backend_code in {40002, 40027, 40038, 404}:
3245
+ if error.backend_code in {40002, 40023, 40027, 40038, 404}:
2929
3246
  return True
2930
3247
  if error.http_status == 404:
2931
3248
  return True
@@ -3059,6 +3376,107 @@ class RecordTools(ToolBase):
3059
3376
  f"({get_record_list_type_label(used_list_type)})."
3060
3377
  ]
3061
3378
 
3379
+ def _looks_like_generic_record_update_backend_failure(self, exc: QingflowApiError) -> bool:
3380
+ if exc.backend_code == 500:
3381
+ return True
3382
+ if exc.http_status is not None and exc.http_status >= 500:
3383
+ return True
3384
+ normalized_message = exc.message.strip().lower()
3385
+ return normalized_message in {"unknown error", "internal server error"}
3386
+
3387
+ def _remap_record_update_target_context_error(
3388
+ self,
3389
+ profile: str,
3390
+ context, # type: ignore[no-untyped-def]
3391
+ *,
3392
+ app_key: str,
3393
+ apply_id: int,
3394
+ exc: QingflowApiError,
3395
+ ) -> None:
3396
+ if not self._looks_like_generic_record_update_backend_failure(exc):
3397
+ return
3398
+ try:
3399
+ candidate_routes = self._candidate_update_views(profile, context, app_key)
3400
+ probes = self._probe_candidate_record_contexts(
3401
+ context,
3402
+ app_key=app_key,
3403
+ apply_id=apply_id,
3404
+ candidate_routes=candidate_routes,
3405
+ )
3406
+ except (QingflowApiError, RuntimeError):
3407
+ return
3408
+ if not probes or any(probe.readable for probe in probes):
3409
+ return
3410
+
3411
+ blocker = (
3412
+ "CURRENT_RECORD_CONTEXT_UNAVAILABLE"
3413
+ if all(probe.transport_error for probe in probes)
3414
+ else "NO_MATCHING_ACCESSIBLE_VIEW_FOR_RECORD"
3415
+ )
3416
+ recommended_next_actions = (
3417
+ [
3418
+ "Retry after the record becomes readable in the current workspace/profile context.",
3419
+ "If the issue persists, verify that the current profile still has read access to this record.",
3420
+ ]
3421
+ if blocker == "CURRENT_RECORD_CONTEXT_UNAVAILABLE"
3422
+ else [
3423
+ "Use record_get or record_list to confirm the record still exists in the current workspace.",
3424
+ "Call record_update_schema_get to inspect whether any accessible view still matches this record.",
3425
+ ]
3426
+ )
3427
+ first_error_payload = next(
3428
+ (
3429
+ cast(JSONObject, probe.error_payload)
3430
+ for probe in probes
3431
+ if isinstance(probe.error_payload, dict)
3432
+ ),
3433
+ None,
3434
+ )
3435
+ backend_code = (
3436
+ cast(int, first_error_payload.get("backend_code"))
3437
+ if isinstance(first_error_payload, dict) and isinstance(first_error_payload.get("backend_code"), int)
3438
+ else exc.backend_code
3439
+ )
3440
+ request_id = (
3441
+ _normalize_optional_text(first_error_payload.get("request_id"))
3442
+ if isinstance(first_error_payload, dict)
3443
+ else None
3444
+ ) or exc.request_id
3445
+ http_status = (
3446
+ cast(int, first_error_payload.get("http_status"))
3447
+ if isinstance(first_error_payload, dict) and isinstance(first_error_payload.get("http_status"), int)
3448
+ else exc.http_status
3449
+ )
3450
+ raise_tool_error(
3451
+ QingflowApiError(
3452
+ category="backend",
3453
+ message=(
3454
+ "Direct record edit was blocked because the current record context could not be loaded from any candidate route."
3455
+ if blocker == "CURRENT_RECORD_CONTEXT_UNAVAILABLE"
3456
+ else "Direct record edit was blocked because the target record is no longer accessible in any readable view for the current profile."
3457
+ ),
3458
+ backend_code=backend_code,
3459
+ request_id=request_id,
3460
+ http_status=http_status,
3461
+ details={
3462
+ "error_code": blocker,
3463
+ "operation": "update",
3464
+ "app_key": app_key,
3465
+ "record_id": apply_id,
3466
+ "blockers": [blocker],
3467
+ "request_route": self._request_route_payload(context),
3468
+ "view_probe_summary": [
3469
+ self._record_context_probe_summary_payload(probe)
3470
+ for probe in probes
3471
+ ],
3472
+ "recommended_next_actions": recommended_next_actions,
3473
+ "fix_hint": (
3474
+ "Confirm the target record still exists and remains visible in at least one accessible view before retrying the update."
3475
+ ),
3476
+ },
3477
+ )
3478
+ )
3479
+
3062
3480
  def _record_matches_accessible_view(
3063
3481
  self,
3064
3482
  context, # type: ignore[no-untyped-def]
@@ -5138,9 +5556,8 @@ class RecordTools(ToolBase):
5138
5556
  source_pages: list[int] = []
5139
5557
  result_amount: int | None = None
5140
5558
  has_more = False
5141
- dept_member_cache: dict[int, set[int]] = {}
5142
5559
  view_selection = resolved_view.view_selection
5143
- local_filtering = bool(filters) or bool(view_selection is not None and view_selection.conditions)
5560
+ local_filtering = bool(filters)
5144
5561
  group_stats: dict[tuple[tuple[str, object], ...], JSONObject] = {}
5145
5562
  overall_metrics = self._initialize_metric_states(metrics)
5146
5563
  matched_rows = 0
@@ -5184,13 +5601,6 @@ class RecordTools(ToolBase):
5184
5601
  continue
5185
5602
  answers = item.get("answers")
5186
5603
  answer_list = answers if isinstance(answers, list) else []
5187
- if not self._matches_view_selection(
5188
- context,
5189
- answer_list,
5190
- view_selection=view_selection,
5191
- dept_member_cache=dept_member_cache,
5192
- ):
5193
- continue
5194
5604
  if not self._matches_analyze_filters(answer_list, filters):
5195
5605
  continue
5196
5606
  matched_rows += 1
@@ -6276,7 +6686,6 @@ class RecordTools(ToolBase):
6276
6686
  if view_selection is not None
6277
6687
  else self._get_field_index(profile, context, app_key, force_refresh=False)
6278
6688
  )
6279
- dept_member_cache: dict[int, set[int]] = {}
6280
6689
  result = self._search_page(
6281
6690
  context,
6282
6691
  app_key=app_key,
@@ -6291,17 +6700,7 @@ class RecordTools(ToolBase):
6291
6700
  )
6292
6701
  rows = result.get("list")
6293
6702
  raw_rows = rows if isinstance(rows, list) else []
6294
- filtered_rows = [
6295
- item
6296
- for item in raw_rows
6297
- if isinstance(item, dict)
6298
- and self._matches_view_selection(
6299
- context,
6300
- item.get("answers") if isinstance(item.get("answers"), list) else [],
6301
- view_selection=view_selection,
6302
- dept_member_cache=dept_member_cache,
6303
- )
6304
- ]
6703
+ filtered_rows = [item for item in raw_rows if isinstance(item, dict)]
6305
6704
  if isinstance(rows, list):
6306
6705
  result = dict(result)
6307
6706
  result["list"] = filtered_rows
@@ -6355,12 +6754,22 @@ class RecordTools(ToolBase):
6355
6754
  force_refresh_form=force_refresh_form,
6356
6755
  )
6357
6756
  self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
6358
- result = self.backend.request(
6359
- "POST",
6360
- context,
6361
- f"/app/{app_key}/apply/{normalized_apply_id}",
6362
- json_body={"role": role, "answers": normalized_answers},
6363
- )
6757
+ try:
6758
+ result = self.backend.request(
6759
+ "POST",
6760
+ context,
6761
+ f"/app/{app_key}/apply/{normalized_apply_id}",
6762
+ json_body={"role": role, "answers": normalized_answers},
6763
+ )
6764
+ except QingflowApiError as exc:
6765
+ self._remap_record_update_target_context_error(
6766
+ profile,
6767
+ context,
6768
+ app_key=app_key,
6769
+ apply_id=normalized_apply_id,
6770
+ exc=exc,
6771
+ )
6772
+ raise
6364
6773
  verification = self._verify_record_write_result(
6365
6774
  context,
6366
6775
  app_key=app_key,
@@ -6541,7 +6950,6 @@ class RecordTools(ToolBase):
6541
6950
  def runner(session_profile, context):
6542
6951
  index = self._get_field_index(profile, context, app_key, force_refresh=False)
6543
6952
  view_selection = self._resolve_view_selection(profile, context, app_key, view_key=view_key, view_name=view_name)
6544
- dept_member_cache: dict[int, set[int]] = {}
6545
6953
  resolved_column_cap = _bounded_column_limit(
6546
6954
  max_columns,
6547
6955
  default_limit=MAX_LIST_COLUMN_LIMIT,
@@ -6647,13 +7055,6 @@ class RecordTools(ToolBase):
6647
7055
  continue
6648
7056
  answers = item.get("answers")
6649
7057
  answer_list = answers if isinstance(answers, list) else []
6650
- if not self._matches_view_selection(
6651
- context,
6652
- answer_list,
6653
- view_selection=view_selection,
6654
- dept_member_cache=dept_member_cache,
6655
- ):
6656
- continue
6657
7058
  matched_records += 1
6658
7059
  apply_id = _coerce_count(item.get("applyId")) or _coerce_count(item.get("id"))
6659
7060
  row = _build_flat_row(answer_list, selected_fields_from_primary, apply_id=apply_id)
@@ -6890,6 +7291,8 @@ class RecordTools(ToolBase):
6890
7291
  isinstance(payload.get("viewgraphLimit"), list)
6891
7292
  or isinstance(payload.get("viewConfig"), dict)
6892
7293
  or isinstance(payload.get("viewgraphConfig"), dict)
7294
+ or isinstance(payload.get("viewgraphQuestions"), list)
7295
+ or isinstance(payload.get("viewgraphQueIds"), list)
6893
7296
  ):
6894
7297
  config = payload
6895
7298
  else:
@@ -8794,7 +9197,13 @@ class RecordTools(ToolBase):
8794
9197
  except json.JSONDecodeError:
8795
9198
  parsed = None
8796
9199
  if isinstance(parsed, dict):
8797
- error_payload["error_code"] = parsed.get("error_code") or cast(JSONObject, parsed.get("details", {})).get("error_code") or error_payload["error_code"]
9200
+ parsed_details = parsed.get("details")
9201
+ details_payload = cast(JSONObject, parsed_details) if isinstance(parsed_details, dict) else None
9202
+ error_payload["error_code"] = (
9203
+ parsed.get("error_code")
9204
+ or (details_payload.get("error_code") if details_payload is not None else None)
9205
+ or error_payload["error_code"]
9206
+ )
8798
9207
  error_payload["message"] = parsed.get("message") or error_payload["message"]
8799
9208
  if parsed.get("backend_code") is not None:
8800
9209
  error_payload["backend_code"] = parsed.get("backend_code")
@@ -8802,6 +9211,8 @@ class RecordTools(ToolBase):
8802
9211
  error_payload["request_id"] = parsed.get("request_id")
8803
9212
  if isinstance(parsed.get("request_route"), dict):
8804
9213
  request_route = cast(JSONObject, parsed.get("request_route"))
9214
+ elif details_payload is not None and isinstance(details_payload.get("request_route"), dict):
9215
+ request_route = cast(JSONObject, details_payload.get("request_route"))
8805
9216
  response: JSONObject = {
8806
9217
  "profile": profile,
8807
9218
  "ws_id": None,
@@ -10089,11 +10500,20 @@ class RecordTools(ToolBase):
10089
10500
  """执行内部辅助逻辑。"""
10090
10501
  if not app_key:
10091
10502
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
10092
- try:
10093
- normalized_apply_id = normalize_positive_id_int(apply_id, field_name="apply_id")
10094
- except QingflowApiError:
10095
- raise_tool_error(QingflowApiError.config_error("apply_id must be positive"))
10096
- return normalized_apply_id
10503
+ return self._normalize_internal_backend_id(apply_id, field_name="apply_id")
10504
+
10505
+ def _normalize_internal_backend_id(self, value: Any, *, field_name: str) -> int:
10506
+ """Normalize backend/apply ids after the public boundary has already preserved long string ids."""
10507
+ if value in (None, "") or isinstance(value, bool):
10508
+ raise_tool_error(QingflowApiError.config_error(f"{field_name} must be positive"))
10509
+ if isinstance(value, int):
10510
+ if value <= 0:
10511
+ raise_tool_error(QingflowApiError.config_error(f"{field_name} must be positive"))
10512
+ return value
10513
+ text = stringify_backend_id(value)
10514
+ if text is None or not text.isdecimal() or int(text) <= 0:
10515
+ raise_tool_error(QingflowApiError.config_error(f"{field_name} must be positive"))
10516
+ return int(text)
10097
10517
 
10098
10518
  def _validate_record_write(self, app_key: str, answers: list[JSONObject], apply_id: int | None = None) -> None:
10099
10519
  """执行内部辅助逻辑。"""
@@ -11035,7 +11455,7 @@ def _list_sample_only(*, returned_items: int, row_cap: int, result_amount: int |
11035
11455
  def _list_sample_warning(*, returned_items: int, row_cap: int, result_amount: int | None) -> str:
11036
11456
  if _list_sample_only(returned_items=returned_items, row_cap=row_cap, result_amount=result_amount):
11037
11457
  return "当前仅返回样本,不适合最终统计结论。"
11038
- return "record_list 适合浏览或导出明细;最终统计结论请改用 record_browse_schema_get -> record_analyze。"
11458
+ return "record_list 适合浏览或样本检查;最终统计结论请改用 record_browse_schema_get -> record_access -> Python。"
11039
11459
 
11040
11460
 
11041
11461
  def _resolve_query_mode(
@@ -11067,6 +11487,56 @@ def _chunk_fields(fields: list[FormField], chunk_size: int) -> list[list[FormFie
11067
11487
  return [fields[index : index + chunk_size] for index in range(0, len(fields), chunk_size)]
11068
11488
 
11069
11489
 
11490
+ def _record_access_run_dir() -> Path:
11491
+ custom_home = os.getenv("QINGFLOW_MCP_RECORD_ACCESS_HOME")
11492
+ base_dir = Path(custom_home).expanduser() if custom_home else get_mcp_home() / "record-access"
11493
+ run_id = f"{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
11494
+ return base_dir / run_id
11495
+
11496
+
11497
+ def _record_access_field_payload(field: FormField) -> JSONObject:
11498
+ return {
11499
+ "field_id": field.que_id,
11500
+ "title": field.que_title,
11501
+ "column_name": f"field_{field.que_id}",
11502
+ "type": _record_access_field_type(field),
11503
+ }
11504
+
11505
+
11506
+ def _record_access_field_type(field: FormField) -> str:
11507
+ if field.que_type in DATE_QUE_TYPES:
11508
+ return "datetime"
11509
+ if field.que_type in NUMBER_QUE_TYPES:
11510
+ return "number"
11511
+ if field.que_type in MEMBER_QUE_TYPES:
11512
+ return "member"
11513
+ if field.que_type in DEPARTMENT_QUE_TYPES:
11514
+ return "department"
11515
+ if field.que_type in ADDRESS_QUE_TYPES:
11516
+ return "address"
11517
+ if field.que_type in RELATION_QUE_TYPES:
11518
+ return "relation"
11519
+ if field.que_type in ATTACHMENT_QUE_TYPES:
11520
+ return "attachment"
11521
+ if field.que_type in SINGLE_SELECT_QUE_TYPES:
11522
+ return "single_select"
11523
+ if field.que_type in MULTI_SELECT_QUE_TYPES:
11524
+ return "multi_select"
11525
+ if field.que_type in SUBTABLE_QUE_TYPES:
11526
+ return "subtable"
11527
+ return "value"
11528
+
11529
+
11530
+ def _record_access_csv_cell(value: JSONValue) -> str:
11531
+ if value is None:
11532
+ return ""
11533
+ if isinstance(value, bool):
11534
+ return "true" if value else "false"
11535
+ if isinstance(value, int | float | str):
11536
+ return str(value)
11537
+ return json.dumps(value, ensure_ascii=False)
11538
+
11539
+
11070
11540
  def _is_board_view_type(view_type: str | None) -> bool:
11071
11541
  normalized = _normalize_optional_text(view_type)
11072
11542
  return normalized is not None and normalized.lower() == "boardview"