@qingflow-tech/qingflow-app-builder-mcp 1.0.4 → 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.
package/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @qingflow-tech/qingflow-app-builder-mcp@1.0.4
6
+ npm install @qingflow-tech/qingflow-app-builder-mcp@1.0.5
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @qingflow-tech/qingflow-app-builder-mcp@1.0.4 qingflow-app-builder-mcp
12
+ npx -y -p @qingflow-tech/qingflow-app-builder-mcp@1.0.5 qingflow-app-builder-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qingflow-tech/qingflow-app-builder-mcp",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Builder MCP for Qingflow app/package/system design and staged solution 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.4"
7
+ version = "1.0.5"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -29,6 +29,7 @@ dependencies = [
29
29
  "openpyxl>=3.1,<4.0",
30
30
  "pydantic>=2.8,<3.0",
31
31
  "pycryptodome>=3.20,<4.0",
32
+ "pypdf>=5.0,<6.0",
32
33
  "python-socketio[client]>=5.11,<6.0",
33
34
  ]
34
35
 
@@ -4,7 +4,7 @@ from dataclasses import dataclass
4
4
  from threading import Event
5
5
  from time import sleep
6
6
  from typing import Any
7
- from urllib.parse import urlsplit, urlunsplit
7
+ from urllib.parse import urljoin, urlsplit, urlunsplit
8
8
  from uuid import uuid4
9
9
 
10
10
  import httpx
@@ -33,6 +33,15 @@ class BackendResponse:
33
33
  qf_response_version: str | None = None
34
34
 
35
35
 
36
+ @dataclass(slots=True)
37
+ class BackendBinaryResponse:
38
+ content: bytes
39
+ headers: dict[str, str]
40
+ http_status: int
41
+ final_url: str
42
+ redirected: bool
43
+
44
+
36
45
  class BackendClient:
37
46
  def __init__(self, timeout: float | None = None, client: httpx.Client | None = None) -> None:
38
47
  self._owns_client = client is None
@@ -290,6 +299,51 @@ class BackendClient:
290
299
  )
291
300
  return response.content
292
301
 
302
+ def download_binary_with_cookie(
303
+ self,
304
+ context: BackendRequestContext,
305
+ url: str,
306
+ *,
307
+ cookie_name: str,
308
+ headers: dict[str, str] | None = None,
309
+ ) -> BackendBinaryResponse:
310
+ qf_version, _source = self._resolve_qf_version(context.qf_version)
311
+ request_headers = {
312
+ "User-Agent": DEFAULT_USER_AGENT,
313
+ "Qf-Request-Id": context.qf_request_id or str(uuid4()),
314
+ }
315
+ if headers:
316
+ request_headers.update({key: value for key, value in headers.items() if value is not None})
317
+ cookie_parts = [f"{cookie_name}={context.token}"]
318
+ if qf_version:
319
+ cookie_parts.append(f"qfVersion={qf_version}")
320
+ request_headers["Cookie"] = "; ".join(cookie_parts)
321
+ try:
322
+ response = self._client.get(url, headers=request_headers, follow_redirects=False)
323
+ except httpx.RequestError as exc:
324
+ raise QingflowApiError(category="network", message=str(exc))
325
+ redirected = 300 <= response.status_code < 400 and bool(response.headers.get("location"))
326
+ if redirected:
327
+ redirect_headers = {key: value for key, value in request_headers.items() if key.lower() != "cookie"}
328
+ redirect_url = urljoin(str(response.url), response.headers["location"])
329
+ try:
330
+ response = self._client.get(redirect_url, headers=redirect_headers)
331
+ except httpx.RequestError as exc:
332
+ raise QingflowApiError(category="network", message=str(exc))
333
+ if response.status_code >= 400:
334
+ raise QingflowApiError(
335
+ category="http",
336
+ message=self._extract_message(response.text) or f"HTTP {response.status_code}",
337
+ http_status=response.status_code,
338
+ )
339
+ return BackendBinaryResponse(
340
+ content=response.content,
341
+ headers=dict(response.headers),
342
+ http_status=response.status_code,
343
+ final_url=str(response.url),
344
+ redirected=redirected or bool(response.history) or str(response.url) != url,
345
+ )
346
+
293
347
  def request_multipart(
294
348
  self,
295
349
  method: str,
@@ -10,7 +10,11 @@ from .common import load_list_arg, load_object_arg, raise_config_error, require_
10
10
 
11
11
  def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
12
12
  parser = subparsers.add_parser("record", help="记录与表结构")
13
- record_subparsers = parser.add_subparsers(dest="record_command", required=True)
13
+ record_subparsers = parser.add_subparsers(
14
+ dest="record_command",
15
+ required=True,
16
+ metavar="{schema,list,access,get,insert,update,delete,code-block-run}",
17
+ )
14
18
 
15
19
  schema = record_subparsers.add_parser("schema", help="读取记录相关表结构")
16
20
  schema.add_argument("--mode", dest="legacy_mode", help=argparse.SUPPRESS)
@@ -47,9 +51,11 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
47
51
  list_parser.add_argument("--app-key", required=True)
48
52
  list_parser.add_argument("--column", dest="columns", action="append", type=int, default=[])
49
53
  list_parser.add_argument("--columns-file")
54
+ list_parser.add_argument("--query")
55
+ list_parser.add_argument("--query-field", dest="query_fields", action="append", type=int, default=[])
56
+ list_parser.add_argument("--query-fields-file")
50
57
  list_parser.add_argument("--where-file")
51
58
  list_parser.add_argument("--order-by-file")
52
- list_parser.add_argument("--limit", type=int, default=20)
53
59
  list_parser.add_argument("--page", type=int, default=1)
54
60
  list_parser.add_argument("--view-id")
55
61
  list_parser.add_argument("--list-type", dest="legacy_list_type", type=int, help=argparse.SUPPRESS)
@@ -57,13 +63,22 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
57
63
  list_parser.add_argument("--view-name", dest="legacy_view_name", help=argparse.SUPPRESS)
58
64
  list_parser.set_defaults(handler=_handle_list, format_hint="record_list")
59
65
 
66
+ access_parser = record_subparsers.add_parser("access", help="访问记录并写入本地 CSV 分片")
67
+ access_parser.add_argument("--app-key", required=True)
68
+ access_parser.add_argument("--column", dest="columns", action="append", type=int, default=[])
69
+ access_parser.add_argument("--columns-file")
70
+ access_parser.add_argument("--where-file")
71
+ access_parser.add_argument("--order-by-file")
72
+ access_parser.add_argument("--view-id", required=True)
73
+ access_parser.set_defaults(handler=_handle_access, format_hint="record_access")
74
+
60
75
  get = record_subparsers.add_parser("get", help="读取单条记录")
61
76
  get.add_argument("--app-key", required=True)
62
77
  get.add_argument("--record-id", required=True)
63
78
  get.add_argument("--column", dest="columns", action="append", type=int, default=[])
64
79
  get.add_argument("--columns-file")
65
80
  get.add_argument("--view-id")
66
- get.set_defaults(handler=_handle_get, format_hint="")
81
+ get.set_defaults(handler=_handle_get, format_hint="record_get")
67
82
 
68
83
  insert = record_subparsers.add_parser("insert", help="新增记录")
69
84
  insert.add_argument("--app-key", required=True)
@@ -86,7 +101,12 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
86
101
  delete.add_argument("--record-ids-file")
87
102
  delete.set_defaults(handler=_handle_delete, format_hint="")
88
103
 
89
- analyze = record_subparsers.add_parser("analyze", help="分析记录数据")
104
+ analyze = record_subparsers.add_parser("analyze", help=argparse.SUPPRESS)
105
+ record_subparsers._choices_actions = [ # type: ignore[attr-defined]
106
+ action
107
+ for action in record_subparsers._choices_actions # type: ignore[attr-defined]
108
+ if getattr(action, "dest", None) != "analyze"
109
+ ]
90
110
  analyze.add_argument("--app-key", required=True)
91
111
  analyze.add_argument("--dimensions-file")
92
112
  analyze.add_argument("--metrics-file")
@@ -122,6 +142,13 @@ def _columns(args: argparse.Namespace) -> list[Any]:
122
142
  return columns
123
143
 
124
144
 
145
+ def _query_fields(args: argparse.Namespace) -> list[Any]:
146
+ query_fields: list[Any] = list(getattr(args, "query_fields", None) or [])
147
+ if getattr(args, "query_fields_file", None):
148
+ query_fields.extend(require_list_arg(args.query_fields_file, option_name="--query-fields-file"))
149
+ return query_fields
150
+
151
+
125
152
  def _handle_schema_root(args: argparse.Namespace, _context: CliContext) -> dict:
126
153
  mode = (args.legacy_mode or "").strip()
127
154
  if mode:
@@ -216,14 +243,26 @@ def _handle_list(args: argparse.Namespace, context: CliContext) -> dict:
216
243
  profile=args.profile,
217
244
  app_key=args.app_key,
218
245
  columns=_columns(args),
246
+ query=args.query,
247
+ query_fields=_query_fields(args),
219
248
  where=load_list_arg(args.where_file, option_name="--where-file"),
220
249
  order_by=load_list_arg(args.order_by_file, option_name="--order-by-file"),
221
- limit=args.limit,
222
250
  page=args.page,
223
251
  view_id=args.view_id,
224
252
  )
225
253
 
226
254
 
255
+ def _handle_access(args: argparse.Namespace, context: CliContext) -> dict:
256
+ return context.record.record_access(
257
+ profile=args.profile,
258
+ app_key=args.app_key,
259
+ columns=_columns(args),
260
+ where=load_list_arg(args.where_file, option_name="--where-file"),
261
+ order_by=load_list_arg(args.order_by_file, option_name="--order-by-file"),
262
+ view_id=args.view_id,
263
+ )
264
+
265
+
227
266
  def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
228
267
  return context.record.record_get_public(
229
268
  profile=args.profile,
@@ -183,7 +183,17 @@ def _format_app_get(result: dict[str, Any]) -> str:
183
183
  def _format_record_list(result: dict[str, Any]) -> str:
184
184
  data = result.get("data") if isinstance(result.get("data"), dict) else {}
185
185
  items = data.get("items") if isinstance(data.get("items"), list) else []
186
- lines = [f"Returned Records: {len(items)}"]
186
+ lines: list[str] = []
187
+ lookup = result.get("lookup") if isinstance(result.get("lookup"), dict) else {}
188
+ if lookup:
189
+ lines.append("Lookup:")
190
+ lines.append(f"- query: {lookup.get('query')}")
191
+ lines.append(f"- confidence: {lookup.get('confidence')}")
192
+ lines.append(f"- next_action: {lookup.get('next_action')}")
193
+ lines.append(f"- total_count: {lookup.get('total_count')}")
194
+ lines.append(f"- returned_count: {lookup.get('returned_count')}")
195
+ lines.append(f"- truncated: {lookup.get('truncated')}")
196
+ lines.append(f"Returned Records: {len(items)}")
187
197
  for item in items[:10]:
188
198
  if isinstance(item, dict):
189
199
  lines.append(json.dumps(item, ensure_ascii=False))
@@ -194,6 +204,94 @@ def _format_record_list(result: dict[str, Any]) -> str:
194
204
  return "\n".join(lines) + "\n"
195
205
 
196
206
 
207
+ def _format_record_access(result: dict[str, Any]) -> str:
208
+ status = result.get("status") or "-"
209
+ lines = [
210
+ f"Status: {status}",
211
+ f"Rows: {result.get('row_count')}",
212
+ f"Complete: {result.get('complete')}",
213
+ f"Safe for final conclusion: {result.get('safe_for_final_conclusion')}",
214
+ ]
215
+ if result.get("local_dir"):
216
+ lines.append(f"Local dir: {result.get('local_dir')}")
217
+ files = result.get("files") if isinstance(result.get("files"), list) else []
218
+ if files:
219
+ lines.append("Files:")
220
+ for item in files:
221
+ if isinstance(item, dict):
222
+ lines.append(f"- part {item.get('part')}: {item.get('local_path')} ({item.get('row_count')} rows)")
223
+ scope = result.get("scope") if isinstance(result.get("scope"), dict) else {}
224
+ if status == "needs_scope" and scope:
225
+ lines.append("Scope required:")
226
+ lines.append(f"- reported_total: {scope.get('reported_total')}")
227
+ lines.append(f"- estimated_pages: {scope.get('estimated_pages')}")
228
+ suggested = scope.get("suggested_time_fields") if isinstance(scope.get("suggested_time_fields"), list) else []
229
+ if suggested:
230
+ names = ", ".join(str(item.get("title") or item.get("field_id")) for item in suggested if isinstance(item, dict))
231
+ lines.append(f"- suggested_time_fields: {names}")
232
+ _append_warnings(lines, result.get("warnings"))
233
+ _append_verification(lines, result.get("verification"))
234
+ return "\n".join(lines) + "\n"
235
+
236
+
237
+ def _format_record_get(result: dict[str, Any]) -> str:
238
+ record = result.get("record") if isinstance(result.get("record"), dict) else {}
239
+ app = result.get("app") if isinstance(result.get("app"), dict) else {}
240
+ view = result.get("view") if isinstance(result.get("view"), dict) else {}
241
+ fields = result.get("fields") if isinstance(result.get("fields"), list) else []
242
+ data_logs = result.get("data_logs") if isinstance(result.get("data_logs"), dict) else {}
243
+ workflow_logs = result.get("workflow_logs") if isinstance(result.get("workflow_logs"), dict) else {}
244
+ media_assets = result.get("media_assets") if isinstance(result.get("media_assets"), dict) else {}
245
+ media_items = media_assets.get("items") if isinstance(media_assets.get("items"), list) else []
246
+ downloaded_media = [item for item in media_items if isinstance(item, dict) and item.get("access_status") == "downloaded"]
247
+ failed_media = [item for item in media_items if isinstance(item, dict) and item.get("access_status") != "downloaded"]
248
+ file_assets = result.get("file_assets") if isinstance(result.get("file_assets"), dict) else {}
249
+ file_items = file_assets.get("items") if isinstance(file_assets.get("items"), list) else []
250
+ downloaded_files = [item for item in file_items if isinstance(item, dict) and item.get("access_status") == "downloaded"]
251
+ failed_files = [item for item in file_items if isinstance(item, dict) and item.get("access_status") != "downloaded"]
252
+ extracted_files = [
253
+ item
254
+ for item in downloaded_files
255
+ if isinstance(item.get("extraction"), dict) and item["extraction"].get("status") == "ok"
256
+ ]
257
+ associated_resources = result.get("associated_resources") if isinstance(result.get("associated_resources"), list) else []
258
+ unavailable_context = result.get("unavailable_context") if isinstance(result.get("unavailable_context"), list) else []
259
+ lines = [
260
+ f"Status: {result.get('status') or '-'}",
261
+ f"App: {app.get('app_name') or app.get('app_key') or '-'} ({app.get('app_key') or '-'})",
262
+ f"View: {view.get('name') or view.get('view_id') or '-'}",
263
+ f"Record: {record.get('title') or '-'} ({record.get('record_id') or '-'})",
264
+ f"Fields: {len(fields)}",
265
+ f"Data logs: {data_logs.get('status') or '-'} / loaded={data_logs.get('items_loaded')}",
266
+ f"Workflow logs: {workflow_logs.get('status') or '-'} / loaded={workflow_logs.get('items_loaded')}",
267
+ f"Media assets: {media_assets.get('status') or '-'} / downloaded={len(downloaded_media)} / failed={len(failed_media)}",
268
+ f"File assets: {file_assets.get('status') or '-'} / downloaded={len(downloaded_files)} / extracted={len(extracted_files)} / failed={len(failed_files)}",
269
+ f"Associated resources: {len(associated_resources)}",
270
+ f"Unavailable contexts: {len(unavailable_context)}",
271
+ ]
272
+ if media_assets.get("local_dir"):
273
+ lines.append(f"Media dir: {media_assets.get('local_dir')}")
274
+ if file_assets.get("local_dir"):
275
+ lines.append(f"File dir: {file_assets.get('local_dir')}")
276
+ if failed_media:
277
+ failure_counts: dict[str, int] = {}
278
+ for item in failed_media:
279
+ key = f"{item.get('access_status') or 'unknown'} via {item.get('download_strategy') or 'unknown'}"
280
+ failure_counts[key] = failure_counts.get(key, 0) + 1
281
+ lines.append("Media failures: " + ", ".join(f"{key}={count}" for key, count in sorted(failure_counts.items())))
282
+ if failed_files:
283
+ failure_counts = {}
284
+ for item in failed_files:
285
+ key = f"{item.get('access_status') or 'unknown'} via {item.get('download_strategy') or 'unknown'}"
286
+ failure_counts[key] = failure_counts.get(key, 0) + 1
287
+ lines.append("File failures: " + ", ".join(f"{key}={count}" for key, count in sorted(failure_counts.items())))
288
+ summary = result.get("summary") if isinstance(result.get("summary"), dict) else {}
289
+ if summary.get("text"):
290
+ lines.append(f"Summary: {summary.get('text')}")
291
+ _append_warnings(lines, result.get("warnings"))
292
+ return "\n".join(lines) + "\n"
293
+
294
+
197
295
  def _format_task_list(result: dict[str, Any]) -> str:
198
296
  data = result.get("data") if isinstance(result.get("data"), dict) else {}
199
297
  items = data.get("items") if isinstance(data.get("items"), list) else []
@@ -693,6 +791,8 @@ _FORMATTERS = {
693
791
  "app_search": _format_app_items,
694
792
  "app_get": _format_app_get,
695
793
  "record_list": _format_record_list,
794
+ "record_access": _format_record_access,
795
+ "record_get": _format_record_get,
696
796
  "task_list": _format_task_list,
697
797
  "task_workbench": _format_task_workbench,
698
798
  "task_get": _format_task_get,
@@ -80,8 +80,9 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
80
80
  ),
81
81
  PublicToolSpec(USER_DOMAIN, "record_member_candidates", ("record_member_candidates",), cli_public=False),
82
82
  PublicToolSpec(USER_DOMAIN, "record_department_candidates", ("record_department_candidates",), cli_public=False),
83
- PublicToolSpec(USER_DOMAIN, "record_analyze", ("record_analyze",), ("record", "analyze")),
83
+ PublicToolSpec(USER_DOMAIN, "record_analyze", ("record_analyze",), ("record", "analyze"), mcp_public=False),
84
84
  PublicToolSpec(USER_DOMAIN, "record_list", ("record_list",), ("record", "list"), cli_show_effective_context=True),
85
+ PublicToolSpec(USER_DOMAIN, "record_access", ("record_access",), ("record", "access"), cli_show_effective_context=True),
85
86
  PublicToolSpec(USER_DOMAIN, "record_get", ("record_get_public",), ("record", "get"), cli_show_effective_context=True),
86
87
  PublicToolSpec(USER_DOMAIN, "record_insert", ("record_insert_public",), ("record", "insert"), cli_show_effective_context=True, cli_context_write=True),
87
88
  PublicToolSpec(USER_DOMAIN, "record_update", ("record_update_public",), ("record", "update"), cli_show_effective_context=True, cli_context_write=True),
@@ -65,7 +65,10 @@ def trim_success_response(tool_name: str | None, payload: dict[str, Any]) -> dic
65
65
  if not isinstance(payload, dict):
66
66
  return payload
67
67
  trimmed = deepcopy(payload)
68
- _drop_top_keys(trimmed, COMMON_SUCCESS_DROP_TOP)
68
+ drop_keys = COMMON_SUCCESS_DROP_TOP
69
+ if tool_name == "user:record_get":
70
+ drop_keys = COMMON_SUCCESS_DROP_TOP - {"output_profile"}
71
+ _drop_top_keys(trimmed, drop_keys)
69
72
  transformer = SUCCESS_POLICY_BY_TOOL.get(tool_name or "")
70
73
  if transformer is not None:
71
74
  transformer(trimmed)
@@ -395,6 +398,9 @@ def _trim_record_write(payload: JSONObject) -> None:
395
398
 
396
399
 
397
400
  def _trim_record_get(payload: JSONObject) -> None:
401
+ if payload.get("fields") is not None or payload.get("semantic_context") is not None:
402
+ _trim_detail_context_record_get(payload)
403
+ return
398
404
  data = payload.get("data")
399
405
  if not isinstance(data, dict):
400
406
  return
@@ -417,22 +423,142 @@ def _trim_record_get(payload: JSONObject) -> None:
417
423
  payload["data"] = compact
418
424
 
419
425
 
426
+ def _trim_detail_context_record_get(payload: JSONObject) -> None:
427
+ _trim_item_list(
428
+ payload,
429
+ "fields",
430
+ allowed=("field_id", "title", "type", "value", "display_value", "requested_focus", "asset_ids", "file_asset_ids"),
431
+ )
432
+ references = payload.get("references")
433
+ if isinstance(references, list):
434
+ compact_refs: list[JSONObject] = []
435
+ for item in references:
436
+ if not isinstance(item, dict):
437
+ continue
438
+ compact = _pick(
439
+ item,
440
+ (
441
+ "field_id",
442
+ "field_title",
443
+ "target_app_key",
444
+ "target_record_id",
445
+ "target_title",
446
+ "target_detail_completeness",
447
+ "self_reference",
448
+ ),
449
+ )
450
+ target_fields = item.get("target_fields") if isinstance(item.get("target_fields"), list) else []
451
+ if target_fields:
452
+ compact["target_fields"] = [
453
+ _pick(field, ("field_id", "title", "type", "value", "display_value", "asset_ids", "file_asset_ids"))
454
+ for field in target_fields
455
+ if isinstance(field, dict)
456
+ ]
457
+ compact_refs.append(compact)
458
+ payload["references"] = compact_refs
459
+ media_assets = payload.get("media_assets")
460
+ if isinstance(media_assets, dict):
461
+ compact_media = _pick(media_assets, ("status", "local_dir", "warnings"))
462
+ items = media_assets.get("items") if isinstance(media_assets.get("items"), list) else []
463
+ compact_media["items"] = [
464
+ _pick(
465
+ item,
466
+ (
467
+ "asset_id",
468
+ "kind",
469
+ "source",
470
+ "field_id",
471
+ "field_title",
472
+ "local_path",
473
+ "access_status",
474
+ "readable_by_agent",
475
+ "mime_type",
476
+ "size_bytes",
477
+ "download_strategy",
478
+ "storage_auth_type",
479
+ "storage_cookie_prefix",
480
+ "redirected",
481
+ ),
482
+ )
483
+ for item in items
484
+ if isinstance(item, dict)
485
+ ]
486
+ payload["media_assets"] = compact_media
487
+ file_assets = payload.get("file_assets")
488
+ if isinstance(file_assets, dict):
489
+ compact_files = _pick(file_assets, ("status", "local_dir", "warnings"))
490
+ items = file_assets.get("items") if isinstance(file_assets.get("items"), list) else []
491
+ compact_files["items"] = [
492
+ _pick(
493
+ item,
494
+ (
495
+ "file_asset_id",
496
+ "media_asset_id",
497
+ "kind",
498
+ "source",
499
+ "field_id",
500
+ "field_title",
501
+ "file_name",
502
+ "local_path",
503
+ "access_status",
504
+ "readable_by_agent",
505
+ "mime_type",
506
+ "size_bytes",
507
+ "download_strategy",
508
+ "storage_auth_type",
509
+ "storage_cookie_prefix",
510
+ "redirected",
511
+ "extraction",
512
+ ),
513
+ )
514
+ for item in items
515
+ if isinstance(item, dict)
516
+ ]
517
+ payload["file_assets"] = compact_files
518
+ for key in ("data_logs", "workflow_logs"):
519
+ node = payload.get(key)
520
+ if not isinstance(node, dict):
521
+ continue
522
+ compact_node = _pick(
523
+ node,
524
+ (
525
+ "status",
526
+ "visible",
527
+ "reason",
528
+ "page",
529
+ "page_size",
530
+ "items_loaded",
531
+ "reported_total",
532
+ "has_more",
533
+ "complete",
534
+ "items",
535
+ ),
536
+ )
537
+ payload[key] = compact_node
538
+ associated_resources = payload.get("associated_resources")
539
+ if isinstance(associated_resources, list):
540
+ payload["associated_resources"] = [
541
+ _pick(item, ("type", "name", "app_key", "app_name", "view_key", "chart_key", "view_type", "data_access"))
542
+ for item in associated_resources
543
+ if isinstance(item, dict)
544
+ ]
545
+ _drop_deep_keys(payload, {"raw", "debug"})
546
+
547
+
420
548
  def _trim_record_list(payload: JSONObject) -> None:
421
549
  data = payload.get("data")
422
550
  if not isinstance(data, dict):
423
551
  return
424
552
  pagination = data.get("pagination") if isinstance(data.get("pagination"), dict) else {}
425
- returned_items = pagination.get("returned_items")
426
- result_amount = pagination.get("result_amount")
427
- limit = pagination.get("limit")
428
- truncated = False
429
- if isinstance(result_amount, int) and isinstance(returned_items, int):
553
+ returned_items = pagination.get("returned_count", pagination.get("returned_items"))
554
+ result_amount = pagination.get("total_count", pagination.get("result_amount"))
555
+ truncated = bool(pagination.get("truncated"))
556
+ if not truncated and isinstance(result_amount, int) and isinstance(returned_items, int):
430
557
  truncated = result_amount > returned_items
431
558
  compact_pagination = {
432
559
  "loaded": True,
433
- "page_size": limit,
434
- "fetched_pages": 1,
435
- "reported_total": result_amount,
560
+ "returned_count": returned_items,
561
+ "total_count": result_amount,
436
562
  "truncated": truncated,
437
563
  }
438
564
  selection = data.get("selection") if isinstance(data.get("selection"), dict) else {}
@@ -445,6 +571,43 @@ def _trim_record_list(payload: JSONObject) -> None:
445
571
  if view:
446
572
  compact["view"] = _pick(view, ("view_id", "name"))
447
573
  payload["data"] = compact
574
+ lookup = payload.get("lookup") if isinstance(payload.get("lookup"), dict) else {}
575
+ if lookup:
576
+ payload["lookup"] = {
577
+ "mode": lookup.get("mode"),
578
+ "query": lookup.get("query"),
579
+ "total_count": lookup.get("total_count", lookup.get("reported_total")),
580
+ "returned_count": lookup.get("returned_count"),
581
+ "truncated": lookup.get("truncated"),
582
+ "confidence": lookup.get("confidence"),
583
+ "next_action": lookup.get("next_action"),
584
+ }
585
+
586
+
587
+ def _trim_record_access(payload: JSONObject) -> None:
588
+ compact: dict[str, Any] = {}
589
+ for key in (
590
+ "ok",
591
+ "status",
592
+ "app_key",
593
+ "view_id",
594
+ "format",
595
+ "local_dir",
596
+ "row_count",
597
+ "complete",
598
+ "truncated",
599
+ "safe_for_final_conclusion",
600
+ "files",
601
+ "fields",
602
+ "warnings",
603
+ "verification",
604
+ "scope",
605
+ ):
606
+ value = payload.get(key)
607
+ if value is not None:
608
+ compact[key] = value
609
+ payload.clear()
610
+ payload.update(compact)
448
611
 
449
612
 
450
613
  def _trim_record_analyze(payload: JSONObject) -> None:
@@ -783,6 +946,7 @@ _register_policy(
783
946
  _register_policy((USER_DOMAIN,), ("record_insert", "record_update"), _trim_record_write)
784
947
  _register_policy((USER_DOMAIN,), ("record_get",), _trim_record_get)
785
948
  _register_policy((USER_DOMAIN,), ("record_list",), _trim_record_list)
949
+ _register_policy((USER_DOMAIN,), ("record_access",), _trim_record_access)
786
950
  _register_policy((USER_DOMAIN,), ("record_analyze",), _trim_record_analyze)
787
951
  _register_policy((USER_DOMAIN,), ("record_code_block_run",), _trim_code_block_run)
788
952
  _register_policy((USER_DOMAIN,), ("task_list",), _trim_task_list)
@@ -50,7 +50,7 @@ All resource tools operate with the logged-in user's Qingflow permissions.
50
50
 
51
51
  If `app_key` is unknown, use `app_list` or `app_search` first.
52
52
  If the app is known but the data range is not, use `app_get` first and choose from `accessible_views`.
53
- If an accessible view has `analysis_supported=false`, do not use it for `record_list` or `record_analyze`. `boardView` and `ganttView` are special UI views, not list/analyze targets.
53
+ If an accessible view has `analysis_supported=false`, do not use it for `record_access` or `record_list`. `boardView` and `ganttView` are special UI views, not data-access targets.
54
54
  `view_get(view_id=...)` also returns `export_capability`; it only means there is a supported export route, not that export permission has been verified.
55
55
 
56
56
  ## Schema-First Rule
@@ -58,7 +58,7 @@ If an accessible view has `analysis_supported=false`, do not use it for `record_
58
58
  Call `record_insert_schema_get` before `record_insert`.
59
59
  Call `record_update_schema_get` before `record_update`.
60
60
  Call `record_code_block_schema_get` before `record_code_block_run`.
61
- Call `app_get` first when the data range is unclear, then use `record_browse_schema_get(view_id=...)` before `record_list`, `record_get`, or `record_analyze`.
61
+ Call `app_get` first when the data range is unclear, then use `record_browse_schema_get(view_id=...)` before `record_access`, `record_list`, or `record_get`.
62
62
  Call `record_import_schema_get` when the import field mapping is unclear before template download or verify.
63
63
 
64
64
  - All `field_id` values must come from the schema response.
@@ -68,7 +68,8 @@ Call `record_import_schema_get` when the import field mapping is unclear before
68
68
 
69
69
  `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`.
70
70
  `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`.
71
- `record_browse_schema_get(view_id=...)` returns browse-schema fields for the selected accessible view.
71
+ `record_browse_schema_get(view_id=...)` returns the same readable fields shown in the selected Qingflow table view header.
72
+ `record_access.fields` / CSV columns and `record_list.columns / where / order_by / query_fields` use that exact same view schema.
72
73
  `record_code_block_schema_get` returns code-block-ready schema for exact code block field selection.
73
74
  `record_import_schema_get` returns import-ready column metadata.
74
75
 
@@ -78,16 +79,17 @@ Call `record_import_schema_get` when the import field mapping is unclear before
78
79
 
79
80
  ## Analytics Path
80
81
 
81
- `app_get -> record_browse_schema_get(view_id=...) -> record_analyze`
82
+ `app_get -> record_browse_schema_get(view_id=...) -> record_access -> Python`
82
83
 
83
84
  Prefer `view_id` entries from `accessible_views` where `analysis_supported=true`.
84
85
 
86
+ Use `record_access` to write local CSV shard files for analysis, then use Python to compute counts, rankings, ratios, trends, and final conclusions.
87
+
85
88
  Use this DSL shape:
86
89
 
87
- - `dimensions`: `{{field_id, alias, bucket}}`
88
- - `metrics`: `{{op, field_id, alias}}`
89
- - `filters`: `{{field_id, op, value}}`
90
- - `sort`: `{{by, order}}`
90
+ - `columns`: `[{{field_id}}]`
91
+ - `where`: `[{{field_id, op, value}}]`
92
+ - `order_by`: `[{{field_id, direction}}]`
91
93
 
92
94
  Important key rules:
93
95
 
@@ -108,6 +110,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
108
110
  `record_code_block_schema_get -> record_code_block_run`
109
111
 
110
112
  - Use `columns` as `[{{field_id}}]`
113
+ - Use `record_list(query=..., query_fields=[{{field_id}}])` for fuzzy single-record lookup, then follow `lookup.next_action`; `query_fields` is search scope and `columns` is display shape.
111
114
  - Use `where` items as `{{field_id, op, value}}`
112
115
  - Use `order_by` items as `{{field_id, direction}}`
113
116
  - Legacy forms such as bare integer `field_id`, `fieldId`, `operator`, `values`, or `order` may still parse, but they are compatibility-only and not the canonical DSL
@@ -121,7 +124,10 @@ Analysis answers must include concrete numbers. When applicable, include percent
121
124
  - `linkage.kind=logic_visibility` means linked visibility or option-driven rules are involved; `linkage.kind=reference_fill` means reference/default matching logic is involved; `linkage.kind=formula_fill` means formula/default auto-fill logic is involved.
122
125
  - `record_update_schema_get` exposes the overall writable field set for the record, but not every field combination is guaranteed; `record_update` still needs one single matched accessible view that can cover the payload.
123
126
  - `record_delete` deletes by `record_id` or `record_ids`.
124
- - When readback shape matters after insert or update, prefer `record_get(..., output_profile="normalized")` or `record_list(..., output_profile="normalized")`.
127
+ - `record_get` is the single-record frontend detail context tool. It returns detail-page visible fields, one-level relation targets, first-page data/workflow logs, associated views/reports, local readable image assets, local downloadable file assets, unavailable context, and `semantic_context`.
128
+ - Read record images from `record_get.media_assets.items[].local_path` when `readable_by_agent=true`; read attachments/documents/tables from `record_get.file_assets.items[].local_path` and `extraction.text_path` when present. `record_get` follows the frontend storage cookie redirect path for Qingflow attachments, and remote file URLs should not be treated as directly readable.
129
+ - `record_get.columns` are focus hints only; they do not project the detail fields. Read facts from top-level `fields[]`.
130
+ - When readback shape matters after insert or update, prefer `record_get` for human/detail-page context, or `record_list(..., output_profile="normalized")` for batch-shaped normalized rows.
125
131
 
126
132
  - Read relation targets from `record_insert_schema_get` / `record_update_schema_get` relation metadata before preparing relation writes.
127
133
  - Member and department fields may be written with natural strings directly on `record_insert` / `record_update`; only fall back to `record_member_candidates` or `record_department_candidates` when the user wants explicit candidate browsing or the write returns ambiguity that needs confirmation.