@qingflow-tech/qingflow-app-builder-mcp 1.0.4 → 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.
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.6
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.6 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.6",
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.6"
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,17 +63,27 @@ 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)
70
- insert.add_argument("--fields-file", required=True)
85
+ insert.add_argument("--fields-file")
86
+ insert.add_argument("--items-file")
71
87
  insert.add_argument("--verify-write", action=argparse.BooleanOptionalAction, default=True)
72
88
  insert.set_defaults(handler=_handle_insert, format_hint="")
73
89
 
@@ -86,7 +102,12 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
86
102
  delete.add_argument("--record-ids-file")
87
103
  delete.set_defaults(handler=_handle_delete, format_hint="")
88
104
 
89
- analyze = record_subparsers.add_parser("analyze", help="分析记录数据")
105
+ analyze = record_subparsers.add_parser("analyze", help=argparse.SUPPRESS)
106
+ record_subparsers._choices_actions = [ # type: ignore[attr-defined]
107
+ action
108
+ for action in record_subparsers._choices_actions # type: ignore[attr-defined]
109
+ if getattr(action, "dest", None) != "analyze"
110
+ ]
90
111
  analyze.add_argument("--app-key", required=True)
91
112
  analyze.add_argument("--dimensions-file")
92
113
  analyze.add_argument("--metrics-file")
@@ -122,6 +143,13 @@ def _columns(args: argparse.Namespace) -> list[Any]:
122
143
  return columns
123
144
 
124
145
 
146
+ def _query_fields(args: argparse.Namespace) -> list[Any]:
147
+ query_fields: list[Any] = list(getattr(args, "query_fields", None) or [])
148
+ if getattr(args, "query_fields_file", None):
149
+ query_fields.extend(require_list_arg(args.query_fields_file, option_name="--query-fields-file"))
150
+ return query_fields
151
+
152
+
125
153
  def _handle_schema_root(args: argparse.Namespace, _context: CliContext) -> dict:
126
154
  mode = (args.legacy_mode or "").strip()
127
155
  if mode:
@@ -216,14 +244,26 @@ def _handle_list(args: argparse.Namespace, context: CliContext) -> dict:
216
244
  profile=args.profile,
217
245
  app_key=args.app_key,
218
246
  columns=_columns(args),
247
+ query=args.query,
248
+ query_fields=_query_fields(args),
219
249
  where=load_list_arg(args.where_file, option_name="--where-file"),
220
250
  order_by=load_list_arg(args.order_by_file, option_name="--order-by-file"),
221
- limit=args.limit,
222
251
  page=args.page,
223
252
  view_id=args.view_id,
224
253
  )
225
254
 
226
255
 
256
+ def _handle_access(args: argparse.Namespace, context: CliContext) -> dict:
257
+ return context.record.record_access(
258
+ profile=args.profile,
259
+ app_key=args.app_key,
260
+ columns=_columns(args),
261
+ where=load_list_arg(args.where_file, option_name="--where-file"),
262
+ order_by=load_list_arg(args.order_by_file, option_name="--order-by-file"),
263
+ view_id=args.view_id,
264
+ )
265
+
266
+
227
267
  def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
228
268
  return context.record.record_get_public(
229
269
  profile=args.profile,
@@ -235,6 +275,23 @@ def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
235
275
 
236
276
 
237
277
  def _handle_insert(args: argparse.Namespace, context: CliContext) -> dict:
278
+ if args.items_file:
279
+ if args.fields_file:
280
+ raise_config_error(
281
+ "record insert batch mode does not accept --fields-file.",
282
+ fix_hint="Use `record insert --app-key APP_KEY --items-file ITEMS.json` for batch inserts.",
283
+ )
284
+ return context.record.record_insert_public(
285
+ profile=args.profile,
286
+ app_key=args.app_key,
287
+ items=require_list_arg(args.items_file, option_name="--items-file"),
288
+ verify_write=bool(args.verify_write),
289
+ )
290
+ if not args.fields_file:
291
+ raise_config_error(
292
+ "record insert requires --items-file or --fields-file.",
293
+ fix_hint="Prefer `record insert --app-key APP_KEY --items-file ITEMS.json`; use --fields-file only for legacy single inserts.",
294
+ )
238
295
  return context.record.record_insert_public(
239
296
  profile=args.profile,
240
297
  app_key=args.app_key,
@@ -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),