@josephyan/qingflow-cli 1.0.10 → 1.0.11
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 +2 -2
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/qingflow_mcp/cli/commands/record.py +0 -2
- package/src/qingflow_mcp/cli/formatters.py +21 -14
- package/src/qingflow_mcp/response_trim.py +42 -32
- package/src/qingflow_mcp/server.py +3 -3
- package/src/qingflow_mcp/server_app_user.py +3 -3
- package/src/qingflow_mcp/tools/record_tools.py +1236 -346
package/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Install:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install @josephyan/qingflow-cli@1.0.
|
|
6
|
+
npm install @josephyan/qingflow-cli@1.0.11
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @josephyan/qingflow-cli@1.0.
|
|
12
|
+
npx -y -p @josephyan/qingflow-cli@1.0.11 qingflow
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
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.
|
|
7
|
+
version = "1.0.11"
|
|
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
|
|
|
@@ -56,7 +56,6 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
56
56
|
list_parser.add_argument("--query-fields-file")
|
|
57
57
|
list_parser.add_argument("--where-file")
|
|
58
58
|
list_parser.add_argument("--order-by-file")
|
|
59
|
-
list_parser.add_argument("--limit", type=int, default=20)
|
|
60
59
|
list_parser.add_argument("--page", type=int, default=1)
|
|
61
60
|
list_parser.add_argument("--view-id")
|
|
62
61
|
list_parser.add_argument("--list-type", dest="legacy_list_type", type=int, help=argparse.SUPPRESS)
|
|
@@ -248,7 +247,6 @@ def _handle_list(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
248
247
|
query_fields=_query_fields(args),
|
|
249
248
|
where=load_list_arg(args.where_file, option_name="--where-file"),
|
|
250
249
|
order_by=load_list_arg(args.order_by_file, option_name="--order-by-file"),
|
|
251
|
-
limit=args.limit,
|
|
252
250
|
page=args.page,
|
|
253
251
|
view_id=args.view_id,
|
|
254
252
|
)
|
|
@@ -190,20 +190,9 @@ def _format_record_list(result: dict[str, Any]) -> str:
|
|
|
190
190
|
lines.append(f"- query: {lookup.get('query')}")
|
|
191
191
|
lines.append(f"- confidence: {lookup.get('confidence')}")
|
|
192
192
|
lines.append(f"- next_action: {lookup.get('next_action')}")
|
|
193
|
-
lines.append(f"-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if not isinstance(candidate, dict):
|
|
197
|
-
continue
|
|
198
|
-
title = candidate.get("title") or "-"
|
|
199
|
-
record_id = candidate.get("record_id") or "-"
|
|
200
|
-
score = candidate.get("score")
|
|
201
|
-
lines.append(f" - {record_id} | score={score} | {title}")
|
|
202
|
-
matches = candidate.get("matched_fields") if isinstance(candidate.get("matched_fields"), list) else []
|
|
203
|
-
if matches:
|
|
204
|
-
match = matches[0]
|
|
205
|
-
if isinstance(match, dict):
|
|
206
|
-
lines.append(f" match: {match.get('title')}={match.get('value')}")
|
|
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')}")
|
|
207
196
|
lines.append(f"Returned Records: {len(items)}")
|
|
208
197
|
for item in items[:10]:
|
|
209
198
|
if isinstance(item, dict):
|
|
@@ -256,6 +245,15 @@ def _format_record_get(result: dict[str, Any]) -> str:
|
|
|
256
245
|
media_items = media_assets.get("items") if isinstance(media_assets.get("items"), list) else []
|
|
257
246
|
downloaded_media = [item for item in media_items if isinstance(item, dict) and item.get("access_status") == "downloaded"]
|
|
258
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
|
+
]
|
|
259
257
|
associated_resources = result.get("associated_resources") if isinstance(result.get("associated_resources"), list) else []
|
|
260
258
|
unavailable_context = result.get("unavailable_context") if isinstance(result.get("unavailable_context"), list) else []
|
|
261
259
|
lines = [
|
|
@@ -267,17 +265,26 @@ def _format_record_get(result: dict[str, Any]) -> str:
|
|
|
267
265
|
f"Data logs: {data_logs.get('status') or '-'} / loaded={data_logs.get('items_loaded')}",
|
|
268
266
|
f"Workflow logs: {workflow_logs.get('status') or '-'} / loaded={workflow_logs.get('items_loaded')}",
|
|
269
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)}",
|
|
270
269
|
f"Associated resources: {len(associated_resources)}",
|
|
271
270
|
f"Unavailable contexts: {len(unavailable_context)}",
|
|
272
271
|
]
|
|
273
272
|
if media_assets.get("local_dir"):
|
|
274
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')}")
|
|
275
276
|
if failed_media:
|
|
276
277
|
failure_counts: dict[str, int] = {}
|
|
277
278
|
for item in failed_media:
|
|
278
279
|
key = f"{item.get('access_status') or 'unknown'} via {item.get('download_strategy') or 'unknown'}"
|
|
279
280
|
failure_counts[key] = failure_counts.get(key, 0) + 1
|
|
280
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())))
|
|
281
288
|
summary = result.get("summary") if isinstance(result.get("summary"), dict) else {}
|
|
282
289
|
if summary.get("text"):
|
|
283
290
|
lines.append(f"Summary: {summary.get('text')}")
|
|
@@ -427,7 +427,7 @@ def _trim_detail_context_record_get(payload: JSONObject) -> None:
|
|
|
427
427
|
_trim_item_list(
|
|
428
428
|
payload,
|
|
429
429
|
"fields",
|
|
430
|
-
allowed=("field_id", "title", "type", "value", "display_value", "requested_focus", "asset_ids"),
|
|
430
|
+
allowed=("field_id", "title", "type", "value", "display_value", "requested_focus", "asset_ids", "file_asset_ids"),
|
|
431
431
|
)
|
|
432
432
|
references = payload.get("references")
|
|
433
433
|
if isinstance(references, list):
|
|
@@ -450,7 +450,7 @@ def _trim_detail_context_record_get(payload: JSONObject) -> None:
|
|
|
450
450
|
target_fields = item.get("target_fields") if isinstance(item.get("target_fields"), list) else []
|
|
451
451
|
if target_fields:
|
|
452
452
|
compact["target_fields"] = [
|
|
453
|
-
_pick(field, ("field_id", "title", "type", "value", "display_value", "asset_ids"))
|
|
453
|
+
_pick(field, ("field_id", "title", "type", "value", "display_value", "asset_ids", "file_asset_ids"))
|
|
454
454
|
for field in target_fields
|
|
455
455
|
if isinstance(field, dict)
|
|
456
456
|
]
|
|
@@ -484,6 +484,37 @@ def _trim_detail_context_record_get(payload: JSONObject) -> None:
|
|
|
484
484
|
if isinstance(item, dict)
|
|
485
485
|
]
|
|
486
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
|
|
487
518
|
for key in ("data_logs", "workflow_logs"):
|
|
488
519
|
node = payload.get(key)
|
|
489
520
|
if not isinstance(node, dict):
|
|
@@ -519,17 +550,15 @@ def _trim_record_list(payload: JSONObject) -> None:
|
|
|
519
550
|
if not isinstance(data, dict):
|
|
520
551
|
return
|
|
521
552
|
pagination = data.get("pagination") if isinstance(data.get("pagination"), dict) else {}
|
|
522
|
-
returned_items = pagination.get("returned_items")
|
|
523
|
-
result_amount = pagination.get("result_amount")
|
|
524
|
-
|
|
525
|
-
truncated
|
|
526
|
-
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):
|
|
527
557
|
truncated = result_amount > returned_items
|
|
528
558
|
compact_pagination = {
|
|
529
559
|
"loaded": True,
|
|
530
|
-
"
|
|
531
|
-
"
|
|
532
|
-
"reported_total": result_amount,
|
|
560
|
+
"returned_count": returned_items,
|
|
561
|
+
"total_count": result_amount,
|
|
533
562
|
"truncated": truncated,
|
|
534
563
|
}
|
|
535
564
|
selection = data.get("selection") if isinstance(data.get("selection"), dict) else {}
|
|
@@ -544,33 +573,14 @@ def _trim_record_list(payload: JSONObject) -> None:
|
|
|
544
573
|
payload["data"] = compact
|
|
545
574
|
lookup = payload.get("lookup") if isinstance(payload.get("lookup"), dict) else {}
|
|
546
575
|
if lookup:
|
|
547
|
-
candidates = lookup.get("candidates") if isinstance(lookup.get("candidates"), list) else []
|
|
548
576
|
payload["lookup"] = {
|
|
549
577
|
"mode": lookup.get("mode"),
|
|
550
578
|
"query": lookup.get("query"),
|
|
551
|
-
"
|
|
552
|
-
"
|
|
579
|
+
"total_count": lookup.get("total_count", lookup.get("reported_total")),
|
|
580
|
+
"returned_count": lookup.get("returned_count"),
|
|
581
|
+
"truncated": lookup.get("truncated"),
|
|
553
582
|
"confidence": lookup.get("confidence"),
|
|
554
583
|
"next_action": lookup.get("next_action"),
|
|
555
|
-
"candidates": [
|
|
556
|
-
{
|
|
557
|
-
"record_id": candidate.get("record_id"),
|
|
558
|
-
"title": candidate.get("title"),
|
|
559
|
-
"score": candidate.get("score"),
|
|
560
|
-
"matched_fields": [
|
|
561
|
-
_pick(match, ("title", "value", "match_type", "score"))
|
|
562
|
-
for match in (candidate.get("matched_fields") if isinstance(candidate.get("matched_fields"), list) else [])
|
|
563
|
-
if isinstance(match, dict)
|
|
564
|
-
],
|
|
565
|
-
"display_fields": [
|
|
566
|
-
_pick(field, ("title", "value"))
|
|
567
|
-
for field in (candidate.get("display_fields") if isinstance(candidate.get("display_fields"), list) else [])
|
|
568
|
-
if isinstance(field, dict)
|
|
569
|
-
],
|
|
570
|
-
}
|
|
571
|
-
for candidate in candidates[:5]
|
|
572
|
-
if isinstance(candidate, dict)
|
|
573
|
-
],
|
|
574
584
|
}
|
|
575
585
|
|
|
576
586
|
|
|
@@ -69,7 +69,7 @@ Call `record_import_schema_get` when the import field mapping is unclear before
|
|
|
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
71
|
`record_browse_schema_get(view_id=...)` returns the same readable fields shown in the selected Qingflow table view header.
|
|
72
|
-
`record_access.fields`
|
|
72
|
+
`record_access.fields` / CSV columns and `record_list.columns / where / order_by / query_fields` use that exact same view schema.
|
|
73
73
|
`record_code_block_schema_get` returns code-block-ready schema for exact code block field selection.
|
|
74
74
|
`record_import_schema_get` returns import-ready column metadata.
|
|
75
75
|
|
|
@@ -124,8 +124,8 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
124
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.
|
|
125
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.
|
|
126
126
|
- `record_delete` deletes by `record_id` or `record_ids`.
|
|
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
|
|
128
|
-
- Read record images from `record_get.media_assets.items[].local_path` when `readable_by_agent=true`; `record_get` follows the frontend storage cookie redirect path for Qingflow attachments, and remote file URLs should not be treated as directly readable.
|
|
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
129
|
- `record_get.columns` are focus hints only; they do not project the detail fields. Read facts from top-level `fields[]`.
|
|
130
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.
|
|
131
131
|
|
|
@@ -61,7 +61,7 @@ Call `record_import_schema_get` when the import field mapping is unclear before
|
|
|
61
61
|
Inside `optional_fields`, any field with `may_become_required=true` is still writable, but may become required when linked visibility or option-driven runtime rules activate.
|
|
62
62
|
`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`.
|
|
63
63
|
`record_browse_schema_get(view_id=...)` returns the same readable fields shown in the selected Qingflow table view header.
|
|
64
|
-
`record_access.fields`
|
|
64
|
+
`record_access.fields` / CSV columns and `record_list.columns / where / order_by / query_fields` use that exact same view schema; a missing field means it is not readable in that view.
|
|
65
65
|
`searchQueIds` is a backend full-text search scope, not an output-column/projection mechanism.
|
|
66
66
|
`record_code_block_schema_get` returns code-block-ready schema for exact code block field selection.
|
|
67
67
|
`record_import_schema_get` returns import-ready column metadata.
|
|
@@ -125,8 +125,8 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
125
125
|
- `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.
|
|
126
126
|
- `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.
|
|
127
127
|
- `record_delete` deletes by `record_id` or `record_ids`.
|
|
128
|
-
- `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
|
|
129
|
-
- Read record images from `record_get.media_assets.items[].local_path` when `readable_by_agent=true`; `record_get` follows the frontend storage cookie redirect path for Qingflow attachments, and remote file URLs should not be treated as directly readable.
|
|
128
|
+
- `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`.
|
|
129
|
+
- 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.
|
|
130
130
|
- `record_get.columns` are focus hints only; they do not project the detail fields. Read facts from top-level `fields[]`.
|
|
131
131
|
- 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.
|
|
132
132
|
|