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

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 +551 -16
  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,9 @@ 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
+ DEFAULT_RECORD_ACCESS_SHARD_ROWS = 5000
36
+ DEFAULT_RECORD_ACCESS_HARD_ROWS = 50000
37
+ DEFAULT_RECORD_ACCESS_TIMEOUT_SECONDS = 60
31
38
  DEFAULT_ANALYSIS_PAGE_SIZE = 1000
32
39
  DEFAULT_SCAN_MAX_PAGES = 10
33
40
  DEFAULT_ANALYSIS_SCAN_MAX_PAGES = 100
@@ -49,6 +56,7 @@ LOOKUP_RELATION_FILTER_PAGE_SIZE = 20
49
56
  MEMBER_QUE_TYPES = {5}
50
57
  DEPARTMENT_QUE_TYPES = {22}
51
58
  DATE_QUE_TYPES = {4}
59
+ NUMBER_QUE_TYPES = {8}
52
60
  ADDRESS_QUE_TYPES = {21}
53
61
  SINGLE_SELECT_QUE_TYPES = {10, 11}
54
62
  MULTI_SELECT_QUE_TYPES = {12, 15}
@@ -337,7 +345,8 @@ class RecordTools(ToolBase):
337
345
 
338
346
  @mcp.tool(
339
347
  description=(
340
- "Run schema-first analytics on a Qingflow app using a restricted DSL. "
348
+ "Run lightweight schema-first analytics on a Qingflow app using a restricted DSL. "
349
+ "This is not the default analysis path; prefer record_access plus Python for final conclusions. "
341
350
  "Use record_browse_schema_get first, then let the model build a DSL with field_id references only. "
342
351
  "dimensions=[] means whole-table summary; dimensions!=[] means grouped analysis. "
343
352
  "This route hides paging and scan-budget controls from callers."
@@ -404,6 +413,29 @@ class RecordTools(ToolBase):
404
413
  output_profile=output_profile,
405
414
  )
406
415
 
416
+ @mcp.tool(
417
+ description=(
418
+ "Access Qingflow records for analysis by writing local CSV shard files. "
419
+ "Use app_get -> record_browse_schema_get first, then pass field_id-only columns, where, and order_by. "
420
+ "This tool hides pagination and row limits from the caller and returns file metadata instead of record items."
421
+ )
422
+ )
423
+ def record_access(
424
+ app_key: str = "",
425
+ view_id: str = "",
426
+ columns: list[JSONObject | int] | None = None,
427
+ where: list[JSONObject] | None = None,
428
+ order_by: list[JSONObject] | None = None,
429
+ ) -> JSONObject:
430
+ return self.record_access(
431
+ profile=DEFAULT_PROFILE,
432
+ app_key=app_key,
433
+ view_id=view_id,
434
+ columns=columns or [],
435
+ where=where or [],
436
+ order_by=order_by or [],
437
+ )
438
+
407
439
  @mcp.tool(description="Read one Qingflow record by record_id. Use record_browse_schema_get first if columns are ambiguous.")
408
440
  def record_get(
409
441
  profile: str = DEFAULT_PROFILE,
@@ -1759,6 +1791,327 @@ class RecordTools(ToolBase):
1759
1791
  }
1760
1792
  return response
1761
1793
 
1794
+ @tool_cn_name("记录访问")
1795
+ def record_access(
1796
+ self,
1797
+ *,
1798
+ profile: str,
1799
+ app_key: str,
1800
+ view_id: str,
1801
+ columns: list[JSONObject | int],
1802
+ where: list[JSONObject],
1803
+ order_by: list[JSONObject],
1804
+ ) -> JSONObject:
1805
+ """Fetch records across pages and write stable CSV shards for local analysis."""
1806
+ if not app_key:
1807
+ raise_tool_error(QingflowApiError.config_error("app_key is required"))
1808
+ if not (view_id or "").strip():
1809
+ raise_tool_error(
1810
+ QingflowApiError.config_error(
1811
+ "record_access requires view_id. Call app_get first and pass accessible_views[].view_id."
1812
+ )
1813
+ )
1814
+ legacy_warnings = _detect_record_list_legacy_warnings(columns=columns, where=where, order_by=order_by)
1815
+ normalized_columns = _normalize_public_column_selectors(columns)
1816
+ if not normalized_columns:
1817
+ raise_tool_error(
1818
+ QingflowApiError.config_error(
1819
+ "columns is required. Call record_browse_schema_get first and pass field_id-only columns."
1820
+ )
1821
+ )
1822
+ view_route, compatibility_warnings = self._resolve_accessible_view_route_for_public(
1823
+ profile=profile,
1824
+ app_key=app_key,
1825
+ view_id=view_id,
1826
+ list_type=None,
1827
+ view_key=None,
1828
+ view_name=None,
1829
+ allow_default=False,
1830
+ )
1831
+ if not _view_type_supports_analysis(view_route.view_type):
1832
+ raise_tool_error(
1833
+ QingflowApiError(
1834
+ category="not_supported",
1835
+ message=(
1836
+ f"record_access does not support view '{view_route.name}' "
1837
+ f"because its type is {view_route.view_type}."
1838
+ ),
1839
+ details={
1840
+ "error_code": "VIEW_ACCESS_UNSUPPORTED",
1841
+ "view_id": view_route.view_id,
1842
+ "view_name": view_route.name,
1843
+ "view_type": view_route.view_type,
1844
+ "fix_hint": "Choose a system view or a custom table-style view from app_get.accessible_views.",
1845
+ },
1846
+ )
1847
+ )
1848
+ filters = self._normalize_record_list_where(where)
1849
+ sorts = self._normalize_record_list_order_by(order_by)
1850
+
1851
+ def runner(session_profile, context):
1852
+ index = self._get_field_index(profile, context, app_key, force_refresh=False)
1853
+ selected_fields = self._resolve_select_columns(
1854
+ normalized_columns,
1855
+ index,
1856
+ max_columns=len(normalized_columns),
1857
+ default_limit=MAX_LIST_COLUMN_LIMIT,
1858
+ )
1859
+ selected_field_batches = _chunk_fields(selected_fields, BACKEND_LIST_SEARCH_FIELD_LIMIT)
1860
+ primary_search_que_ids: list[int] | None = [field.que_id for field in selected_field_batches[0]]
1861
+ view_selection = view_route.view_selection
1862
+ if view_selection is not None and not _view_selection_supported_by_search_ids(
1863
+ view_selection,
1864
+ primary_search_que_ids,
1865
+ ):
1866
+ primary_search_que_ids = None
1867
+ remaining_field_batches: list[list[FormField]] = []
1868
+ else:
1869
+ remaining_field_batches = selected_field_batches[1:]
1870
+ primary_search_que_ids = primary_search_que_ids or None
1871
+ match_rules = self._resolve_match_rules(context, filters, index)
1872
+ sort_rules = self._resolve_sorts(sorts, index)
1873
+ dept_member_cache: dict[int, set[int]] = {}
1874
+ used_list_type: int | None = None
1875
+ fallback_list_types = (
1876
+ [view_route.list_type if view_route.list_type is not None else DEFAULT_RECORD_LIST_TYPE]
1877
+ if view_selection is not None or view_route.list_type is not None
1878
+ else [DEFAULT_RECORD_LIST_TYPE, 14, 1, 2, 12]
1879
+ )
1880
+
1881
+ run_dir = _record_access_run_dir()
1882
+ run_dir.mkdir(parents=True, exist_ok=True)
1883
+ header = ["record_id"] + [f"field_{field.que_id}" for field in selected_fields]
1884
+ files: list[JSONObject] = []
1885
+ shard_part = 0
1886
+ shard_rows = 0
1887
+ row_count = 0
1888
+ file_handle: Any | None = None
1889
+ writer: Any | None = None
1890
+
1891
+ def close_shard() -> None:
1892
+ nonlocal file_handle, writer, shard_rows
1893
+ if file_handle is None:
1894
+ return
1895
+ path = Path(file_handle.name)
1896
+ file_handle.close()
1897
+ files.append(
1898
+ {
1899
+ "local_path": str(path),
1900
+ "row_count": shard_rows,
1901
+ "part": len(files) + 1,
1902
+ }
1903
+ )
1904
+ file_handle = None
1905
+ writer = None
1906
+ shard_rows = 0
1907
+
1908
+ def ensure_writer() -> Any:
1909
+ nonlocal file_handle, writer, shard_part
1910
+ if writer is not None:
1911
+ return writer
1912
+ shard_part += 1
1913
+ path = run_dir / f"records-{shard_part:04d}.csv"
1914
+ file_handle = path.open("w", newline="", encoding="utf-8-sig")
1915
+ writer = csv.writer(file_handle)
1916
+ writer.writerow(header)
1917
+ return writer
1918
+
1919
+ def write_record(apply_id: int | None, answer_list: list[JSONValue]) -> None:
1920
+ nonlocal row_count, shard_rows
1921
+ if apply_id is None:
1922
+ return
1923
+ if shard_rows >= DEFAULT_RECORD_ACCESS_SHARD_ROWS:
1924
+ close_shard()
1925
+ csv_writer = ensure_writer()
1926
+ values: list[str] = [_public_record_id_text(apply_id) or ""]
1927
+ for field in selected_fields:
1928
+ answer = _find_answer_for_field(answer_list, field)
1929
+ values.append(_record_access_csv_cell(_normalize_answer_field_value_for_output(answer, field)))
1930
+ csv_writer.writerow(values)
1931
+ row_count += 1
1932
+ shard_rows += 1
1933
+
1934
+ current_page = 1
1935
+ has_more = False
1936
+ reported_total: int | None = None
1937
+ limit_reached = False
1938
+ timeout_reached = False
1939
+ started_at = time.perf_counter()
1940
+ try:
1941
+ 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
+ if used_list_type is None:
1949
+ last_error: QingflowApiError | None = None
1950
+ page: JSONObject | None = None
1951
+ for candidate_list_type in fallback_list_types:
1952
+ try:
1953
+ page = self._search_page(
1954
+ context,
1955
+ app_key=app_key,
1956
+ view_selection=view_selection,
1957
+ page_num=current_page,
1958
+ page_size=DEFAULT_LIST_PAGE_SIZE,
1959
+ query_key=None,
1960
+ match_rules=match_rules,
1961
+ sorts=sort_rules,
1962
+ search_que_ids=primary_search_que_ids,
1963
+ list_type=candidate_list_type,
1964
+ )
1965
+ used_list_type = None if view_selection is not None else candidate_list_type
1966
+ break
1967
+ except QingflowApiError as exc:
1968
+ last_error = exc
1969
+ if (
1970
+ self._should_retry_list_type_fallback(exc)
1971
+ and candidate_list_type != fallback_list_types[-1]
1972
+ ):
1973
+ continue
1974
+ raise
1975
+ if page is None:
1976
+ if last_error is not None:
1977
+ raise last_error
1978
+ raise_tool_error(QingflowApiError.config_error("record_access failed: no accessible listType"))
1979
+ else:
1980
+ page = self._search_page(
1981
+ context,
1982
+ app_key=app_key,
1983
+ view_selection=view_selection,
1984
+ page_num=current_page,
1985
+ page_size=DEFAULT_LIST_PAGE_SIZE,
1986
+ query_key=None,
1987
+ match_rules=match_rules,
1988
+ sorts=sort_rules,
1989
+ search_que_ids=primary_search_que_ids,
1990
+ list_type=used_list_type,
1991
+ )
1992
+ page_rows = page.get("list")
1993
+ items = page_rows if isinstance(page_rows, list) else []
1994
+ 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))
1997
+ page_apply_order: list[int] = []
1998
+ page_answer_map: dict[int, list[JSONValue]] = {}
1999
+ for item in items:
2000
+ if row_count + len(page_apply_order) >= DEFAULT_RECORD_ACCESS_HARD_ROWS:
2001
+ limit_reached = True
2002
+ break
2003
+ if not isinstance(item, dict):
2004
+ continue
2005
+ answers = item.get("answers")
2006
+ 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
+ apply_id = _coerce_count(item.get("applyId")) or _coerce_count(item.get("id"))
2015
+ if apply_id is None:
2016
+ continue
2017
+ page_apply_order.append(apply_id)
2018
+ page_answer_map[apply_id] = cast(list[JSONValue], answer_list)
2019
+ if page_apply_order and remaining_field_batches:
2020
+ for batch in remaining_field_batches:
2021
+ extra_page = self._search_page(
2022
+ context,
2023
+ app_key=app_key,
2024
+ view_selection=view_selection,
2025
+ page_num=current_page,
2026
+ page_size=DEFAULT_LIST_PAGE_SIZE,
2027
+ query_key=None,
2028
+ match_rules=match_rules,
2029
+ sorts=sort_rules,
2030
+ search_que_ids=[field.que_id for field in batch],
2031
+ list_type=used_list_type or DEFAULT_RECORD_LIST_TYPE,
2032
+ )
2033
+ extra_rows = extra_page.get("list")
2034
+ extra_items = extra_rows if isinstance(extra_rows, list) else []
2035
+ for extra_item in extra_items:
2036
+ if not isinstance(extra_item, dict):
2037
+ continue
2038
+ apply_id = _coerce_count(extra_item.get("applyId")) or _coerce_count(extra_item.get("id"))
2039
+ if apply_id is None or apply_id not in page_answer_map:
2040
+ continue
2041
+ extra_answers = extra_item.get("answers")
2042
+ extra_answer_list = extra_answers if isinstance(extra_answers, list) else []
2043
+ page_answer_map[apply_id] = _merge_answer_lists_by_field_id(
2044
+ page_answer_map.get(apply_id, []),
2045
+ cast(list[JSONValue], extra_answer_list),
2046
+ )
2047
+ for apply_id in page_apply_order:
2048
+ if row_count >= DEFAULT_RECORD_ACCESS_HARD_ROWS:
2049
+ limit_reached = True
2050
+ break
2051
+ write_record(apply_id, page_answer_map.get(apply_id, []))
2052
+ if limit_reached or not has_more:
2053
+ break
2054
+ current_page += 1
2055
+ finally:
2056
+ close_shard()
2057
+
2058
+ warnings: list[JSONObject] = []
2059
+ warnings.extend(legacy_warnings)
2060
+ warnings.extend(compatibility_warnings)
2061
+ warnings.extend(_view_filter_trust_warnings(view_route))
2062
+ if used_list_type is not None and used_list_type != DEFAULT_RECORD_LIST_TYPE:
2063
+ warnings.append(
2064
+ {
2065
+ "code": "LIST_TYPE_FALLBACK",
2066
+ "message": (
2067
+ f"record_access not accessible via listType={DEFAULT_RECORD_LIST_TYPE}; "
2068
+ f"fell back to listType={used_list_type} ({get_record_list_type_label(used_list_type)})."
2069
+ ),
2070
+ }
2071
+ )
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
2087
+ safe_for_final_conclusion = complete and not any(
2088
+ warning.get("code") == "CUSTOM_VIEW_FILTER_UNVERIFIED" for warning in warnings
2089
+ )
2090
+ return {
2091
+ "profile": profile,
2092
+ "ws_id": session_profile.selected_ws_id,
2093
+ "ok": True,
2094
+ "status": "success" if complete else "partial",
2095
+ "app_key": app_key,
2096
+ "view_id": view_route.view_id,
2097
+ "format": "csv",
2098
+ "row_count": row_count,
2099
+ "complete": complete,
2100
+ "truncated": not complete,
2101
+ "safe_for_final_conclusion": safe_for_final_conclusion,
2102
+ "files": files,
2103
+ "fields": [_record_access_field_payload(field) for field in selected_fields],
2104
+ "warnings": warnings,
2105
+ "verification": {
2106
+ **_view_filter_verification_payload(view_route),
2107
+ "reported_total": reported_total,
2108
+ "list_type_used": used_list_type,
2109
+ },
2110
+ "request_route": self._request_route_payload(context),
2111
+ }
2112
+
2113
+ return self._run_record_tool(profile, runner)
2114
+
1762
2115
  @tool_cn_name("记录详情")
1763
2116
  def record_get_public(
1764
2117
  self,
@@ -2920,12 +3273,14 @@ class RecordTools(ToolBase):
2920
3273
  payload["category"] = error.category
2921
3274
  if error.backend_code is not None:
2922
3275
  payload["backend_code"] = error.backend_code
3276
+ if error.request_id is not None:
3277
+ payload["request_id"] = error.request_id
2923
3278
  if error.http_status is not None:
2924
3279
  payload["http_status"] = error.http_status
2925
3280
  return payload
2926
3281
 
2927
3282
  def _is_record_context_route_miss(self, error: QingflowApiError) -> bool:
2928
- if error.backend_code in {40002, 40027, 40038, 404}:
3283
+ if error.backend_code in {40002, 40023, 40027, 40038, 404}:
2929
3284
  return True
2930
3285
  if error.http_status == 404:
2931
3286
  return True
@@ -3059,6 +3414,107 @@ class RecordTools(ToolBase):
3059
3414
  f"({get_record_list_type_label(used_list_type)})."
3060
3415
  ]
3061
3416
 
3417
+ def _looks_like_generic_record_update_backend_failure(self, exc: QingflowApiError) -> bool:
3418
+ if exc.backend_code == 500:
3419
+ return True
3420
+ if exc.http_status is not None and exc.http_status >= 500:
3421
+ return True
3422
+ normalized_message = exc.message.strip().lower()
3423
+ return normalized_message in {"unknown error", "internal server error"}
3424
+
3425
+ def _remap_record_update_target_context_error(
3426
+ self,
3427
+ profile: str,
3428
+ context, # type: ignore[no-untyped-def]
3429
+ *,
3430
+ app_key: str,
3431
+ apply_id: int,
3432
+ exc: QingflowApiError,
3433
+ ) -> None:
3434
+ if not self._looks_like_generic_record_update_backend_failure(exc):
3435
+ return
3436
+ try:
3437
+ candidate_routes = self._candidate_update_views(profile, context, app_key)
3438
+ probes = self._probe_candidate_record_contexts(
3439
+ context,
3440
+ app_key=app_key,
3441
+ apply_id=apply_id,
3442
+ candidate_routes=candidate_routes,
3443
+ )
3444
+ except (QingflowApiError, RuntimeError):
3445
+ return
3446
+ if not probes or any(probe.readable for probe in probes):
3447
+ return
3448
+
3449
+ blocker = (
3450
+ "CURRENT_RECORD_CONTEXT_UNAVAILABLE"
3451
+ if all(probe.transport_error for probe in probes)
3452
+ else "NO_MATCHING_ACCESSIBLE_VIEW_FOR_RECORD"
3453
+ )
3454
+ recommended_next_actions = (
3455
+ [
3456
+ "Retry after the record becomes readable in the current workspace/profile context.",
3457
+ "If the issue persists, verify that the current profile still has read access to this record.",
3458
+ ]
3459
+ if blocker == "CURRENT_RECORD_CONTEXT_UNAVAILABLE"
3460
+ else [
3461
+ "Use record_get or record_list to confirm the record still exists in the current workspace.",
3462
+ "Call record_update_schema_get to inspect whether any accessible view still matches this record.",
3463
+ ]
3464
+ )
3465
+ first_error_payload = next(
3466
+ (
3467
+ cast(JSONObject, probe.error_payload)
3468
+ for probe in probes
3469
+ if isinstance(probe.error_payload, dict)
3470
+ ),
3471
+ None,
3472
+ )
3473
+ backend_code = (
3474
+ cast(int, first_error_payload.get("backend_code"))
3475
+ if isinstance(first_error_payload, dict) and isinstance(first_error_payload.get("backend_code"), int)
3476
+ else exc.backend_code
3477
+ )
3478
+ request_id = (
3479
+ _normalize_optional_text(first_error_payload.get("request_id"))
3480
+ if isinstance(first_error_payload, dict)
3481
+ else None
3482
+ ) or exc.request_id
3483
+ http_status = (
3484
+ cast(int, first_error_payload.get("http_status"))
3485
+ if isinstance(first_error_payload, dict) and isinstance(first_error_payload.get("http_status"), int)
3486
+ else exc.http_status
3487
+ )
3488
+ raise_tool_error(
3489
+ QingflowApiError(
3490
+ category="backend",
3491
+ message=(
3492
+ "Direct record edit was blocked because the current record context could not be loaded from any candidate route."
3493
+ if blocker == "CURRENT_RECORD_CONTEXT_UNAVAILABLE"
3494
+ else "Direct record edit was blocked because the target record is no longer accessible in any readable view for the current profile."
3495
+ ),
3496
+ backend_code=backend_code,
3497
+ request_id=request_id,
3498
+ http_status=http_status,
3499
+ details={
3500
+ "error_code": blocker,
3501
+ "operation": "update",
3502
+ "app_key": app_key,
3503
+ "record_id": apply_id,
3504
+ "blockers": [blocker],
3505
+ "request_route": self._request_route_payload(context),
3506
+ "view_probe_summary": [
3507
+ self._record_context_probe_summary_payload(probe)
3508
+ for probe in probes
3509
+ ],
3510
+ "recommended_next_actions": recommended_next_actions,
3511
+ "fix_hint": (
3512
+ "Confirm the target record still exists and remains visible in at least one accessible view before retrying the update."
3513
+ ),
3514
+ },
3515
+ )
3516
+ )
3517
+
3062
3518
  def _record_matches_accessible_view(
3063
3519
  self,
3064
3520
  context, # type: ignore[no-untyped-def]
@@ -6355,12 +6811,22 @@ class RecordTools(ToolBase):
6355
6811
  force_refresh_form=force_refresh_form,
6356
6812
  )
6357
6813
  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
- )
6814
+ try:
6815
+ result = self.backend.request(
6816
+ "POST",
6817
+ context,
6818
+ f"/app/{app_key}/apply/{normalized_apply_id}",
6819
+ json_body={"role": role, "answers": normalized_answers},
6820
+ )
6821
+ except QingflowApiError as exc:
6822
+ self._remap_record_update_target_context_error(
6823
+ profile,
6824
+ context,
6825
+ app_key=app_key,
6826
+ apply_id=normalized_apply_id,
6827
+ exc=exc,
6828
+ )
6829
+ raise
6364
6830
  verification = self._verify_record_write_result(
6365
6831
  context,
6366
6832
  app_key=app_key,
@@ -6890,6 +7356,8 @@ class RecordTools(ToolBase):
6890
7356
  isinstance(payload.get("viewgraphLimit"), list)
6891
7357
  or isinstance(payload.get("viewConfig"), dict)
6892
7358
  or isinstance(payload.get("viewgraphConfig"), dict)
7359
+ or isinstance(payload.get("viewgraphQuestions"), list)
7360
+ or isinstance(payload.get("viewgraphQueIds"), list)
6893
7361
  ):
6894
7362
  config = payload
6895
7363
  else:
@@ -8794,7 +9262,13 @@ class RecordTools(ToolBase):
8794
9262
  except json.JSONDecodeError:
8795
9263
  parsed = None
8796
9264
  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"]
9265
+ parsed_details = parsed.get("details")
9266
+ details_payload = cast(JSONObject, parsed_details) if isinstance(parsed_details, dict) else None
9267
+ error_payload["error_code"] = (
9268
+ parsed.get("error_code")
9269
+ or (details_payload.get("error_code") if details_payload is not None else None)
9270
+ or error_payload["error_code"]
9271
+ )
8798
9272
  error_payload["message"] = parsed.get("message") or error_payload["message"]
8799
9273
  if parsed.get("backend_code") is not None:
8800
9274
  error_payload["backend_code"] = parsed.get("backend_code")
@@ -8802,6 +9276,8 @@ class RecordTools(ToolBase):
8802
9276
  error_payload["request_id"] = parsed.get("request_id")
8803
9277
  if isinstance(parsed.get("request_route"), dict):
8804
9278
  request_route = cast(JSONObject, parsed.get("request_route"))
9279
+ elif details_payload is not None and isinstance(details_payload.get("request_route"), dict):
9280
+ request_route = cast(JSONObject, details_payload.get("request_route"))
8805
9281
  response: JSONObject = {
8806
9282
  "profile": profile,
8807
9283
  "ws_id": None,
@@ -10089,11 +10565,20 @@ class RecordTools(ToolBase):
10089
10565
  """执行内部辅助逻辑。"""
10090
10566
  if not app_key:
10091
10567
  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
10568
+ return self._normalize_internal_backend_id(apply_id, field_name="apply_id")
10569
+
10570
+ def _normalize_internal_backend_id(self, value: Any, *, field_name: str) -> int:
10571
+ """Normalize backend/apply ids after the public boundary has already preserved long string ids."""
10572
+ if value in (None, "") or isinstance(value, bool):
10573
+ raise_tool_error(QingflowApiError.config_error(f"{field_name} must be positive"))
10574
+ if isinstance(value, int):
10575
+ if value <= 0:
10576
+ raise_tool_error(QingflowApiError.config_error(f"{field_name} must be positive"))
10577
+ return value
10578
+ text = stringify_backend_id(value)
10579
+ if text is None or not text.isdecimal() or int(text) <= 0:
10580
+ raise_tool_error(QingflowApiError.config_error(f"{field_name} must be positive"))
10581
+ return int(text)
10097
10582
 
10098
10583
  def _validate_record_write(self, app_key: str, answers: list[JSONObject], apply_id: int | None = None) -> None:
10099
10584
  """执行内部辅助逻辑。"""
@@ -11035,7 +11520,7 @@ def _list_sample_only(*, returned_items: int, row_cap: int, result_amount: int |
11035
11520
  def _list_sample_warning(*, returned_items: int, row_cap: int, result_amount: int | None) -> str:
11036
11521
  if _list_sample_only(returned_items=returned_items, row_cap=row_cap, result_amount=result_amount):
11037
11522
  return "当前仅返回样本,不适合最终统计结论。"
11038
- return "record_list 适合浏览或导出明细;最终统计结论请改用 record_browse_schema_get -> record_analyze。"
11523
+ return "record_list 适合浏览或样本检查;最终统计结论请改用 record_browse_schema_get -> record_access -> Python。"
11039
11524
 
11040
11525
 
11041
11526
  def _resolve_query_mode(
@@ -11067,6 +11552,56 @@ def _chunk_fields(fields: list[FormField], chunk_size: int) -> list[list[FormFie
11067
11552
  return [fields[index : index + chunk_size] for index in range(0, len(fields), chunk_size)]
11068
11553
 
11069
11554
 
11555
+ def _record_access_run_dir() -> Path:
11556
+ custom_home = os.getenv("QINGFLOW_MCP_RECORD_ACCESS_HOME")
11557
+ base_dir = Path(custom_home).expanduser() if custom_home else get_mcp_home() / "record-access"
11558
+ run_id = f"{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
11559
+ return base_dir / run_id
11560
+
11561
+
11562
+ def _record_access_field_payload(field: FormField) -> JSONObject:
11563
+ return {
11564
+ "field_id": field.que_id,
11565
+ "title": field.que_title,
11566
+ "column_name": f"field_{field.que_id}",
11567
+ "type": _record_access_field_type(field),
11568
+ }
11569
+
11570
+
11571
+ def _record_access_field_type(field: FormField) -> str:
11572
+ if field.que_type in DATE_QUE_TYPES:
11573
+ return "datetime"
11574
+ if field.que_type in NUMBER_QUE_TYPES:
11575
+ return "number"
11576
+ if field.que_type in MEMBER_QUE_TYPES:
11577
+ return "member"
11578
+ if field.que_type in DEPARTMENT_QUE_TYPES:
11579
+ return "department"
11580
+ if field.que_type in ADDRESS_QUE_TYPES:
11581
+ return "address"
11582
+ if field.que_type in RELATION_QUE_TYPES:
11583
+ return "relation"
11584
+ if field.que_type in ATTACHMENT_QUE_TYPES:
11585
+ return "attachment"
11586
+ if field.que_type in SINGLE_SELECT_QUE_TYPES:
11587
+ return "single_select"
11588
+ if field.que_type in MULTI_SELECT_QUE_TYPES:
11589
+ return "multi_select"
11590
+ if field.que_type in SUBTABLE_QUE_TYPES:
11591
+ return "subtable"
11592
+ return "value"
11593
+
11594
+
11595
+ def _record_access_csv_cell(value: JSONValue) -> str:
11596
+ if value is None:
11597
+ return ""
11598
+ if isinstance(value, bool):
11599
+ return "true" if value else "false"
11600
+ if isinstance(value, int | float | str):
11601
+ return str(value)
11602
+ return json.dumps(value, ensure_ascii=False)
11603
+
11604
+
11070
11605
  def _is_board_view_type(view_type: str | None) -> bool:
11071
11606
  normalized = _normalize_optional_text(view_type)
11072
11607
  return normalized is not None and normalized.lower() == "boardview"