qingflow-mcp 0.2.6 → 0.3.0

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 (3) hide show
  1. package/README.md +58 -4
  2. package/dist/server.js +732 -61
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,6 +7,7 @@ This MCP server wraps Qingflow OpenAPI for:
7
7
  - `qf_records_list`
8
8
  - `qf_record_get`
9
9
  - `qf_query` (unified read entry: list / record / summary)
10
+ - `qf_records_aggregate` (deterministic grouped metrics)
10
11
  - `qf_record_create`
11
12
  - `qf_record_update`
12
13
  - `qf_operation_get`
@@ -110,6 +111,10 @@ MCP client config example:
110
111
  3. `qf_record_create` or `qf_record_update`.
111
112
  4. If create/update returns only `request_id`, call `qf_operation_get` to resolve async result.
112
113
 
114
+ Full calling contract (Chinese):
115
+
116
+ - [MCP 调用规范](./docs/MCP_CALLING_SPEC.md)
117
+
113
118
  ## Unified Query (`qf_query`)
114
119
 
115
120
  `qf_query` is the recommended read entry for agents.
@@ -119,6 +124,11 @@ MCP client config example:
119
124
  - if summary params are set (`amount_column` / `time_range` / `stat_policy` / `scan_max_pages`), route to summary query.
120
125
  - otherwise route to list query.
121
126
  2. `query_mode=list|record|summary` forces explicit behavior.
127
+ 3. In `list` mode, `time_range` is translated to list filters when `from` or `to` is provided.
128
+ 4. In `list` mode, `select_columns` is required.
129
+ 5. In `list` mode, row cap defaults to 200 when `max_rows` and `max_items` are omitted.
130
+ 6. In `record` mode, `select_columns` is required.
131
+ 7. In `summary` mode, `select_columns` is required (`max_rows` defaults to 200 when omitted).
122
132
 
123
133
  Summary mode output:
124
134
 
@@ -130,6 +140,30 @@ Return shape:
130
140
 
131
141
  1. success: structured payload `{ "ok": true, "data": ..., "meta": ... }`
132
142
  2. failure: MCP `isError=true`, and text content is JSON payload like `{ "ok": false, "message": ..., ... }`
143
+ 3. incomplete strict queries fail with `{ "code": "NEED_MORE_DATA", "status": "need_more_data", ... }`
144
+
145
+ Deterministic read protocol (list/summary/aggregate):
146
+
147
+ 1. `completeness` is always returned:
148
+ - `result_amount`
149
+ - `returned_items`
150
+ - `fetched_pages`
151
+ - `requested_pages`
152
+ - `actual_scanned_pages`
153
+ - `has_more`
154
+ - `next_page_token`
155
+ - `is_complete`
156
+ - `partial`
157
+ - `omitted_items`
158
+ - `omitted_chars`
159
+ 2. `evidence` is always returned:
160
+ - `query_id`
161
+ - `app_key`
162
+ - `filters`
163
+ - `selected_columns`
164
+ - `time_range`
165
+ - `source_pages`
166
+ 3. `strict_full=true` makes incomplete results fail fast with `NEED_MORE_DATA`.
133
167
 
134
168
  ## List Query Tips
135
169
 
@@ -141,10 +175,13 @@ Strict mode (`qf_records_list`):
141
175
 
142
176
  1. For `qf_records_list.sort[].que_id`, use a real field `que_id` (numeric) or exact field title from `qf_form_get`.
143
177
  2. Avoid aliases like `create_time`; Qingflow often rejects them.
144
- 3. Use `max_rows` (or `max_items`) to cap returned rows.
178
+ 3. Use `max_rows` (or `max_items`) to cap returned rows. Default row cap is 200.
145
179
  4. Use `max_columns` to cap answers per row when `include_answers=true`.
146
180
  5. Use `select_columns` to return only specific columns (supports `que_id` or exact field title).
147
- 6. When `include_answers=true`, the server still auto-limits by response size to protect MCP context.
181
+ 6. The server may still trim by response-size guardrail (`QINGFLOW_LIST_MAX_ITEMS_BYTES`) when payload is too large.
182
+ 7. Use `requested_pages` and `scan_max_pages` for deterministic page scan.
183
+ 8. Continue with `page_token` from previous `next_page_token`.
184
+ 9. Column limits: `select_columns <= 10`, `max_columns <= 10`.
148
185
 
149
186
  Example:
150
187
 
@@ -153,10 +190,13 @@ Example:
153
190
  "app_key": "your_app_key",
154
191
  "mode": "all",
155
192
  "page_size": 50,
193
+ "requested_pages": 1,
194
+ "scan_max_pages": 1,
156
195
  "include_answers": true,
157
196
  "max_rows": 10,
158
197
  "max_columns": 5,
159
- "select_columns": [1, "客户名称", "1003"]
198
+ "select_columns": [1, "客户名称", "1003"],
199
+ "strict_full": false
160
200
  }
161
201
  ```
162
202
 
@@ -170,10 +210,24 @@ For single record details (`qf_record_get`), the same column controls are suppor
170
210
  }
171
211
  ```
172
212
 
213
+ `qf_record_get` requires `select_columns`.
214
+
215
+ Aggregate example (`qf_records_aggregate`):
216
+
217
+ ```json
218
+ {
219
+ "app_key": "your_app_key",
220
+ "group_by": ["归属部门", "归属销售"],
221
+ "amount_column": "报价总金额",
222
+ "requested_pages": 50,
223
+ "scan_max_pages": 50,
224
+ "strict_full": true
225
+ }
226
+ ```
227
+
173
228
  Optional env vars:
174
229
 
175
230
  ```bash
176
- export QINGFLOW_LIST_MAX_ITEMS_WITH_ANSWERS=5
177
231
  export QINGFLOW_LIST_MAX_ITEMS_BYTES=400000
178
232
  ```
179
233
 
package/dist/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { randomUUID } from "node:crypto";
4
5
  import { z } from "zod";
5
6
  import { QingflowApiError, QingflowClient } from "./qingflow-client.js";
6
7
  const MODE_TO_TYPE = {
@@ -17,10 +18,20 @@ const MODE_TO_TYPE = {
17
18
  all_processing: 11,
18
19
  cc: 12
19
20
  };
21
+ class NeedMoreDataError extends Error {
22
+ code = "NEED_MORE_DATA";
23
+ details;
24
+ constructor(message, details) {
25
+ super(message);
26
+ this.name = "NeedMoreDataError";
27
+ this.details = details;
28
+ }
29
+ }
20
30
  const FORM_CACHE_TTL_MS = Number(process.env.QINGFLOW_FORM_CACHE_TTL_MS ?? "300000");
21
31
  const formCache = new Map();
22
32
  const DEFAULT_PAGE_SIZE = 50;
23
- const DEFAULT_MAX_ITEMS_WITH_ANSWERS = toPositiveInt(process.env.QINGFLOW_LIST_MAX_ITEMS_WITH_ANSWERS) ?? 5;
33
+ const DEFAULT_SCAN_MAX_PAGES = 50;
34
+ const DEFAULT_ROW_LIMIT = 200;
24
35
  const MAX_LIST_ITEMS_BYTES = toPositiveInt(process.env.QINGFLOW_LIST_MAX_ITEMS_BYTES) ?? 400000;
25
36
  const accessToken = process.env.QINGFLOW_ACCESS_TOKEN;
26
37
  const baseUrl = process.env.QINGFLOW_BASE_URL;
@@ -36,7 +47,7 @@ const client = new QingflowClient({
36
47
  });
37
48
  const server = new McpServer({
38
49
  name: "qingflow-mcp",
39
- version: "0.2.6"
50
+ version: "0.3.0"
40
51
  });
41
52
  const jsonPrimitiveSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
42
53
  const answerValueSchema = z.union([
@@ -90,6 +101,34 @@ const apiMetaSchema = z.object({
90
101
  provider_err_msg: z.string().nullable(),
91
102
  base_url: z.string()
92
103
  });
104
+ const completenessSchema = z.object({
105
+ result_amount: z.number().int().nonnegative(),
106
+ returned_items: z.number().int().nonnegative(),
107
+ fetched_pages: z.number().int().nonnegative(),
108
+ requested_pages: z.number().int().positive(),
109
+ actual_scanned_pages: z.number().int().nonnegative(),
110
+ has_more: z.boolean(),
111
+ next_page_token: z.string().nullable(),
112
+ is_complete: z.boolean(),
113
+ partial: z.boolean(),
114
+ omitted_items: z.number().int().nonnegative(),
115
+ omitted_chars: z.number().int().nonnegative()
116
+ });
117
+ const evidenceSchema = z.object({
118
+ query_id: z.string(),
119
+ app_key: z.string(),
120
+ filters: z.array(z.record(z.unknown())),
121
+ selected_columns: z.array(z.string()),
122
+ time_range: z
123
+ .object({
124
+ column: z.string(),
125
+ from: z.string().nullable(),
126
+ to: z.string().nullable(),
127
+ timezone: z.string().nullable()
128
+ })
129
+ .nullable(),
130
+ source_pages: z.array(z.number().int().positive())
131
+ });
93
132
  const appSchema = z.object({
94
133
  appKey: z.string(),
95
134
  appName: z.string()
@@ -154,7 +193,10 @@ const listInputSchema = z
154
193
  app_key: z.string().min(1),
155
194
  user_id: z.string().min(1).optional(),
156
195
  page_num: z.number().int().positive().optional(),
196
+ page_token: z.string().min(1).optional(),
157
197
  page_size: z.number().int().positive().max(200).optional(),
198
+ requested_pages: z.number().int().positive().max(500).optional(),
199
+ scan_max_pages: z.number().int().positive().max(500).optional(),
158
200
  mode: z
159
201
  .enum([
160
202
  "todo",
@@ -193,15 +235,27 @@ const listInputSchema = z
193
235
  search_user_ids: z.array(z.string()).optional()
194
236
  }))
195
237
  .optional(),
238
+ time_range: z
239
+ .object({
240
+ column: z.union([z.string().min(1), z.number().int()]),
241
+ from: z.string().optional(),
242
+ to: z.string().optional(),
243
+ timezone: z.string().optional()
244
+ })
245
+ .optional(),
196
246
  max_rows: z.number().int().positive().max(200).optional(),
197
247
  max_items: z.number().int().positive().max(200).optional(),
198
- max_columns: z.number().int().positive().max(200).optional(),
248
+ max_columns: z.number().int().positive().max(10).optional(),
199
249
  // Strict mode: callers must explicitly choose columns.
200
- select_columns: z.array(z.union([z.string().min(1), z.number().int()])).min(1).max(200),
201
- include_answers: z.boolean().optional()
250
+ select_columns: z.array(z.union([z.string().min(1), z.number().int()])).min(1).max(10),
251
+ include_answers: z.boolean().optional(),
252
+ strict_full: z.boolean().optional()
202
253
  })
203
254
  .refine((value) => value.include_answers !== false, {
204
255
  message: "include_answers=false is not allowed in strict column mode"
256
+ })
257
+ .refine((value) => !(value.page_num !== undefined && value.page_token !== undefined), {
258
+ message: "page_num and page_token cannot be used together"
205
259
  });
206
260
  const listSuccessOutputSchema = z.object({
207
261
  ok: z.literal(true),
@@ -221,19 +275,20 @@ const listSuccessOutputSchema = z.object({
221
275
  column_cap: z.number().int().positive().nullable(),
222
276
  selected_columns: z.array(z.string())
223
277
  })
224
- .optional()
278
+ .optional(),
279
+ completeness: completenessSchema,
280
+ evidence: evidenceSchema
225
281
  }),
226
282
  meta: apiMetaSchema
227
283
  });
228
284
  const listOutputSchema = listSuccessOutputSchema;
229
285
  const recordGetInputSchema = z.object({
230
286
  apply_id: z.union([z.string().min(1), z.number().int()]),
231
- max_columns: z.number().int().positive().max(200).optional(),
287
+ max_columns: z.number().int().positive().max(10).optional(),
232
288
  select_columns: z
233
289
  .array(z.union([z.string().min(1), z.number().int()]))
234
290
  .min(1)
235
- .max(200)
236
- .optional()
291
+ .max(10)
237
292
  });
238
293
  const recordGetSuccessOutputSchema = z.object({
239
294
  ok: z.literal(true),
@@ -246,7 +301,13 @@ const recordGetSuccessOutputSchema = z.object({
246
301
  column_cap: z.number().int().positive().nullable(),
247
302
  selected_columns: z.array(z.string()).nullable()
248
303
  })
249
- .optional()
304
+ .optional(),
305
+ completeness: completenessSchema,
306
+ evidence: z.object({
307
+ query_id: z.string(),
308
+ apply_id: z.string(),
309
+ selected_columns: z.array(z.string())
310
+ })
250
311
  }),
251
312
  meta: apiMetaSchema
252
313
  });
@@ -316,7 +377,9 @@ const queryInputSchema = z.object({
316
377
  apply_id: z.union([z.string().min(1), z.number().int()]).optional(),
317
378
  user_id: z.string().min(1).optional(),
318
379
  page_num: z.number().int().positive().optional(),
380
+ page_token: z.string().min(1).optional(),
319
381
  page_size: z.number().int().positive().max(200).optional(),
382
+ requested_pages: z.number().int().positive().max(500).optional(),
320
383
  mode: z
321
384
  .enum([
322
385
  "todo",
@@ -357,11 +420,11 @@ const queryInputSchema = z.object({
357
420
  .optional(),
358
421
  max_rows: z.number().int().positive().max(200).optional(),
359
422
  max_items: z.number().int().positive().max(200).optional(),
360
- max_columns: z.number().int().positive().max(200).optional(),
423
+ max_columns: z.number().int().positive().max(10).optional(),
361
424
  select_columns: z
362
425
  .array(z.union([z.string().min(1), z.number().int()]))
363
426
  .min(1)
364
- .max(200)
427
+ .max(10)
365
428
  .optional(),
366
429
  include_answers: z.boolean().optional(),
367
430
  amount_column: z.union([z.string().min(1), z.number().int()]).optional(),
@@ -379,7 +442,11 @@ const queryInputSchema = z.object({
379
442
  include_null: z.boolean().optional()
380
443
  })
381
444
  .optional(),
382
- scan_max_pages: z.number().int().positive().max(500).optional()
445
+ scan_max_pages: z.number().int().positive().max(500).optional(),
446
+ strict_full: z.boolean().optional()
447
+ })
448
+ .refine((value) => !(value.page_num !== undefined && value.page_token !== undefined), {
449
+ message: "page_num and page_token cannot be used together"
383
450
  });
384
451
  const querySummaryOutputSchema = z.object({
385
452
  summary: z.object({
@@ -393,6 +460,8 @@ const querySummaryOutputSchema = z.object({
393
460
  missing_count: z.number().int().nonnegative()
394
461
  }),
395
462
  rows: z.array(z.record(z.unknown())),
463
+ completeness: completenessSchema,
464
+ evidence: evidenceSchema,
396
465
  meta: z.object({
397
466
  field_mapping: z.array(z.object({
398
467
  role: z.enum(["row", "amount", "time"]),
@@ -438,6 +507,108 @@ const querySuccessOutputSchema = z.object({
438
507
  meta: apiMetaSchema
439
508
  });
440
509
  const queryOutputSchema = querySuccessOutputSchema;
510
+ const aggregateInputSchema = z
511
+ .object({
512
+ app_key: z.string().min(1),
513
+ user_id: z.string().min(1).optional(),
514
+ page_num: z.number().int().positive().optional(),
515
+ page_token: z.string().min(1).optional(),
516
+ page_size: z.number().int().positive().max(200).optional(),
517
+ requested_pages: z.number().int().positive().max(500).optional(),
518
+ scan_max_pages: z.number().int().positive().max(500).optional(),
519
+ mode: z
520
+ .enum([
521
+ "todo",
522
+ "done",
523
+ "mine_approved",
524
+ "mine_rejected",
525
+ "mine_draft",
526
+ "mine_need_improve",
527
+ "mine_processing",
528
+ "all",
529
+ "all_approved",
530
+ "all_rejected",
531
+ "all_processing",
532
+ "cc"
533
+ ])
534
+ .optional(),
535
+ type: z.number().int().min(1).max(12).optional(),
536
+ keyword: z.string().optional(),
537
+ query_logic: z.enum(["and", "or"]).optional(),
538
+ apply_ids: z.array(z.union([z.string(), z.number()])).optional(),
539
+ sort: z
540
+ .array(z.object({
541
+ que_id: z.union([z.string().min(1), z.number().int()]),
542
+ ascend: z.boolean().optional()
543
+ }))
544
+ .optional(),
545
+ filters: z
546
+ .array(z.object({
547
+ que_id: z.union([z.string().min(1), z.number().int()]).optional(),
548
+ search_key: z.string().optional(),
549
+ search_keys: z.array(z.string()).optional(),
550
+ min_value: z.string().optional(),
551
+ max_value: z.string().optional(),
552
+ scope: z.number().int().optional(),
553
+ search_options: z.array(z.union([z.string(), z.number()])).optional(),
554
+ search_user_ids: z.array(z.string()).optional()
555
+ }))
556
+ .optional(),
557
+ time_range: z
558
+ .object({
559
+ column: z.union([z.string().min(1), z.number().int()]),
560
+ from: z.string().optional(),
561
+ to: z.string().optional(),
562
+ timezone: z.string().optional()
563
+ })
564
+ .optional(),
565
+ group_by: z.array(z.union([z.string().min(1), z.number().int()])).min(1).max(20),
566
+ amount_column: z.union([z.string().min(1), z.number().int()]).optional(),
567
+ stat_policy: z
568
+ .object({
569
+ include_negative: z.boolean().optional(),
570
+ include_null: z.boolean().optional()
571
+ })
572
+ .optional(),
573
+ max_groups: z.number().int().positive().max(2000).optional(),
574
+ strict_full: z.boolean().optional()
575
+ })
576
+ .refine((value) => !(value.page_num !== undefined && value.page_token !== undefined), {
577
+ message: "page_num and page_token cannot be used together"
578
+ });
579
+ const aggregateOutputSchema = z.object({
580
+ ok: z.literal(true),
581
+ data: z.object({
582
+ app_key: z.string(),
583
+ summary: z.object({
584
+ total_count: z.number().int().nonnegative(),
585
+ total_amount: z.number().nullable()
586
+ }),
587
+ groups: z.array(z.object({
588
+ group: z.record(z.unknown()),
589
+ count: z.number().int().nonnegative(),
590
+ count_ratio: z.number().min(0).max(1),
591
+ amount_total: z.number().nullable(),
592
+ amount_ratio: z.number().nullable()
593
+ })),
594
+ completeness: completenessSchema,
595
+ evidence: evidenceSchema,
596
+ meta: z.object({
597
+ field_mapping: z.array(z.object({
598
+ role: z.enum(["group_by", "amount", "time"]),
599
+ requested: z.string(),
600
+ que_id: z.union([z.string(), z.number()]),
601
+ que_title: z.string().nullable(),
602
+ que_type: z.unknown()
603
+ })),
604
+ stat_policy: z.object({
605
+ include_negative: z.boolean(),
606
+ include_null: z.boolean()
607
+ })
608
+ })
609
+ }),
610
+ meta: apiMetaSchema
611
+ });
441
612
  server.registerTool("qf_apps_list", {
442
613
  title: "Qingflow Apps List",
443
614
  description: "List Qingflow apps with optional filtering and client-side slicing.",
@@ -709,6 +880,24 @@ server.registerTool("qf_operation_get", {
709
880
  return errorResult(error);
710
881
  }
711
882
  });
883
+ server.registerTool("qf_records_aggregate", {
884
+ title: "Qingflow Records Aggregate",
885
+ description: "Aggregate records by group_by columns with optional amount metrics. Designed for deterministic, auditable statistics.",
886
+ inputSchema: aggregateInputSchema,
887
+ outputSchema: aggregateOutputSchema,
888
+ annotations: {
889
+ readOnlyHint: true,
890
+ idempotentHint: true
891
+ }
892
+ }, async (args) => {
893
+ try {
894
+ const executed = await executeRecordsAggregate(args);
895
+ return okResult(executed.payload, executed.message);
896
+ }
897
+ catch (error) {
898
+ return errorResult(error);
899
+ }
900
+ });
712
901
  async function main() {
713
902
  const transport = new StdioServerTransport();
714
903
  await server.connect(transport);
@@ -724,6 +913,63 @@ function buildMeta(response) {
724
913
  base_url: baseUrl
725
914
  };
726
915
  }
916
+ function resolveStartPage(pageNum, pageToken, appKey) {
917
+ if (!pageToken) {
918
+ return pageNum ?? 1;
919
+ }
920
+ const payload = decodeContinuationToken(pageToken);
921
+ if (payload.app_key !== appKey) {
922
+ throw new Error(`page_token app_key mismatch: token for ${payload.app_key}, request for ${appKey}`);
923
+ }
924
+ return payload.next_page_num;
925
+ }
926
+ function encodeContinuationToken(payload) {
927
+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
928
+ }
929
+ function decodeContinuationToken(token) {
930
+ let parsed;
931
+ try {
932
+ const decoded = Buffer.from(token, "base64url").toString("utf8");
933
+ parsed = JSON.parse(decoded);
934
+ }
935
+ catch {
936
+ throw new Error("Invalid page_token");
937
+ }
938
+ const obj = asObject(parsed);
939
+ const appKey = asNullableString(obj?.app_key);
940
+ const nextPageNum = toPositiveInt(obj?.next_page_num);
941
+ const pageSize = toPositiveInt(obj?.page_size);
942
+ if (!appKey || !nextPageNum || !pageSize) {
943
+ throw new Error("Invalid page_token payload");
944
+ }
945
+ return {
946
+ app_key: appKey,
947
+ next_page_num: nextPageNum,
948
+ page_size: pageSize
949
+ };
950
+ }
951
+ function buildEvidencePayload(state, sourcePages) {
952
+ return {
953
+ query_id: state.query_id,
954
+ app_key: state.app_key,
955
+ filters: state.filters,
956
+ selected_columns: state.selected_columns,
957
+ time_range: state.time_range,
958
+ source_pages: sourcePages
959
+ };
960
+ }
961
+ function echoFilters(filters) {
962
+ return (filters ?? []).map((item) => ({
963
+ ...(item.que_id !== undefined ? { que_id: String(item.que_id) } : {}),
964
+ ...(item.search_key !== undefined ? { search_key: item.search_key } : {}),
965
+ ...(item.search_keys !== undefined ? { search_keys: item.search_keys } : {}),
966
+ ...(item.min_value !== undefined ? { min_value: item.min_value } : {}),
967
+ ...(item.max_value !== undefined ? { max_value: item.max_value } : {}),
968
+ ...(item.scope !== undefined ? { scope: item.scope } : {}),
969
+ ...(item.search_options !== undefined ? { search_options: item.search_options } : {}),
970
+ ...(item.search_user_ids !== undefined ? { search_user_ids: item.search_user_ids } : {})
971
+ }));
972
+ }
727
973
  function resolveQueryMode(args) {
728
974
  const requested = args.query_mode ?? "auto";
729
975
  if (requested !== "auto") {
@@ -747,29 +993,65 @@ function buildListArgsFromQuery(args) {
747
993
  if (!args.select_columns?.length) {
748
994
  throw new Error("select_columns is required for list query");
749
995
  }
996
+ const filters = buildListFiltersFromQuery(args);
750
997
  return listInputSchema.parse({
751
998
  app_key: args.app_key,
752
999
  user_id: args.user_id,
753
1000
  page_num: args.page_num,
1001
+ page_token: args.page_token,
754
1002
  page_size: args.page_size,
1003
+ requested_pages: args.requested_pages,
1004
+ scan_max_pages: args.scan_max_pages,
755
1005
  mode: args.mode,
756
1006
  type: args.type,
757
1007
  keyword: args.keyword,
758
1008
  query_logic: args.query_logic,
759
1009
  apply_ids: args.apply_ids,
760
1010
  sort: args.sort,
761
- filters: args.filters,
1011
+ filters,
1012
+ time_range: args.time_range,
762
1013
  max_rows: args.max_rows,
763
1014
  max_items: args.max_items,
764
1015
  max_columns: args.max_columns,
765
1016
  select_columns: args.select_columns,
766
- include_answers: args.include_answers
1017
+ include_answers: args.include_answers,
1018
+ strict_full: args.strict_full
767
1019
  });
768
1020
  }
1021
+ function buildListFiltersFromQuery(args) {
1022
+ return appendTimeRangeFilter(args.filters, args.time_range);
1023
+ }
1024
+ function appendTimeRangeFilter(inputFilters, timeRange) {
1025
+ const filters = [...(inputFilters ?? [])];
1026
+ if (!timeRange) {
1027
+ return filters.length > 0 ? filters : undefined;
1028
+ }
1029
+ if (timeRange.from === undefined && timeRange.to === undefined) {
1030
+ return filters.length > 0 ? filters : undefined;
1031
+ }
1032
+ const timeSelector = normalizeColumnSelector(timeRange.column);
1033
+ const alreadyHasTimeFilter = filters.some((item) => {
1034
+ if (item.que_id === undefined) {
1035
+ return false;
1036
+ }
1037
+ return normalizeColumnSelector(item.que_id) === timeSelector;
1038
+ });
1039
+ if (!alreadyHasTimeFilter) {
1040
+ filters.push({
1041
+ que_id: timeRange.column,
1042
+ ...(timeRange.from !== undefined ? { min_value: timeRange.from } : {}),
1043
+ ...(timeRange.to !== undefined ? { max_value: timeRange.to } : {})
1044
+ });
1045
+ }
1046
+ return filters.length > 0 ? filters : undefined;
1047
+ }
769
1048
  function buildRecordGetArgsFromQuery(args) {
770
1049
  if (args.apply_id === undefined) {
771
1050
  throw new Error("apply_id is required for record query");
772
1051
  }
1052
+ if (!args.select_columns?.length) {
1053
+ throw new Error("select_columns is required for record query");
1054
+ }
773
1055
  return recordGetInputSchema.parse({
774
1056
  apply_id: args.apply_id,
775
1057
  max_columns: args.max_columns,
@@ -777,31 +1059,61 @@ function buildRecordGetArgsFromQuery(args) {
777
1059
  });
778
1060
  }
779
1061
  async function executeRecordsList(args) {
780
- const pageNum = args.page_num ?? 1;
1062
+ const queryId = randomUUID();
1063
+ const pageNum = resolveStartPage(args.page_num, args.page_token, args.app_key);
781
1064
  const pageSize = args.page_size ?? DEFAULT_PAGE_SIZE;
1065
+ const requestedPages = args.requested_pages ?? 1;
1066
+ const scanMaxPages = args.scan_max_pages ?? requestedPages;
1067
+ const effectiveFilters = appendTimeRangeFilter(args.filters, args.time_range);
782
1068
  const normalizedSort = await normalizeListSort(args.sort, args.app_key, args.user_id);
783
1069
  const includeAnswers = true;
784
- const payload = buildListPayload({
785
- pageNum,
786
- pageSize,
787
- mode: args.mode,
788
- type: args.type,
789
- keyword: args.keyword,
790
- queryLogic: args.query_logic,
791
- applyIds: args.apply_ids,
792
- sort: normalizedSort,
793
- filters: args.filters
794
- });
795
- const response = await client.listRecords(args.app_key, payload, { userId: args.user_id });
796
- const result = asObject(response.result);
797
- const rawItems = asArray(result?.result);
1070
+ let currentPage = pageNum;
1071
+ let fetchedPages = 0;
1072
+ let hasMore = false;
1073
+ let nextPageNum = null;
1074
+ let resultAmount = null;
1075
+ let pageAmount = null;
1076
+ let responseMeta = null;
1077
+ const sourcePages = [];
1078
+ const collectedRawItems = [];
1079
+ while (fetchedPages < requestedPages && fetchedPages < scanMaxPages) {
1080
+ const payload = buildListPayload({
1081
+ pageNum: currentPage,
1082
+ pageSize,
1083
+ mode: args.mode,
1084
+ type: args.type,
1085
+ keyword: args.keyword,
1086
+ queryLogic: args.query_logic,
1087
+ applyIds: args.apply_ids,
1088
+ sort: normalizedSort,
1089
+ filters: effectiveFilters
1090
+ });
1091
+ const response = await client.listRecords(args.app_key, payload, { userId: args.user_id });
1092
+ responseMeta = responseMeta ?? buildMeta(response);
1093
+ const result = asObject(response.result);
1094
+ const rawItems = asArray(result?.result);
1095
+ collectedRawItems.push(...rawItems);
1096
+ sourcePages.push(currentPage);
1097
+ fetchedPages += 1;
1098
+ resultAmount = resultAmount ?? toNonNegativeInt(result?.resultAmount);
1099
+ pageAmount = pageAmount ?? toPositiveInt(result?.pageAmount);
1100
+ hasMore = pageAmount !== null ? currentPage < pageAmount : rawItems.length === pageSize;
1101
+ nextPageNum = hasMore ? currentPage + 1 : null;
1102
+ if (!hasMore) {
1103
+ break;
1104
+ }
1105
+ currentPage = currentPage + 1;
1106
+ }
1107
+ if (!responseMeta) {
1108
+ throw new Error("Failed to fetch list pages");
1109
+ }
1110
+ const knownResultAmount = resultAmount ?? collectedRawItems.length;
798
1111
  const listLimit = resolveListItemLimit({
799
- total: rawItems.length,
1112
+ total: collectedRawItems.length,
800
1113
  requestedMaxRows: args.max_rows,
801
- requestedMaxItems: args.max_items,
802
- includeAnswers
1114
+ requestedMaxItems: args.max_items
803
1115
  });
804
- const items = rawItems
1116
+ const items = collectedRawItems
805
1117
  .slice(0, listLimit.limit)
806
1118
  .map((raw) => normalizeRecordItem(raw, includeAnswers));
807
1119
  const columnProjection = projectRecordItemsColumns({
@@ -820,15 +1132,61 @@ async function executeRecordsList(args) {
820
1132
  limitBytes: MAX_LIST_ITEMS_BYTES
821
1133
  });
822
1134
  const truncationReason = mergeTruncationReasons(listLimit.reason, columnProjection.reason, fitted.reason);
1135
+ const omittedItems = Math.max(0, knownResultAmount - fitted.items.length);
1136
+ const isComplete = !hasMore &&
1137
+ omittedItems === 0 &&
1138
+ fitted.omittedItems === 0 &&
1139
+ fitted.omittedChars === 0;
1140
+ const nextPageToken = hasMore && nextPageNum
1141
+ ? encodeContinuationToken({
1142
+ app_key: args.app_key,
1143
+ next_page_num: nextPageNum,
1144
+ page_size: pageSize
1145
+ })
1146
+ : null;
1147
+ const completeness = {
1148
+ result_amount: knownResultAmount,
1149
+ returned_items: fitted.items.length,
1150
+ fetched_pages: fetchedPages,
1151
+ requested_pages: requestedPages,
1152
+ actual_scanned_pages: fetchedPages,
1153
+ has_more: hasMore,
1154
+ next_page_token: nextPageToken,
1155
+ is_complete: isComplete,
1156
+ partial: !isComplete,
1157
+ omitted_items: omittedItems,
1158
+ omitted_chars: fitted.omittedChars
1159
+ };
1160
+ const listState = {
1161
+ query_id: queryId,
1162
+ app_key: args.app_key,
1163
+ selected_columns: columnProjection.selectedColumns,
1164
+ filters: echoFilters(effectiveFilters),
1165
+ time_range: args.time_range
1166
+ ? {
1167
+ column: String(args.time_range.column),
1168
+ from: args.time_range.from ?? null,
1169
+ to: args.time_range.to ?? null,
1170
+ timezone: args.time_range.timezone ?? null
1171
+ }
1172
+ : null
1173
+ };
1174
+ if (args.strict_full && !isComplete) {
1175
+ throw new NeedMoreDataError("List result is incomplete. Increase requested_pages/max_rows or continue with next_page_token.", {
1176
+ code: "NEED_MORE_DATA",
1177
+ completeness,
1178
+ evidence: buildEvidencePayload(listState, sourcePages)
1179
+ });
1180
+ }
823
1181
  const responsePayload = {
824
1182
  ok: true,
825
1183
  data: {
826
1184
  app_key: args.app_key,
827
1185
  pagination: {
828
- page_num: toPositiveInt(result?.pageNum) ?? pageNum,
829
- page_size: toPositiveInt(result?.pageSize) ?? pageSize,
830
- page_amount: toNonNegativeInt(result?.pageAmount),
831
- result_amount: toNonNegativeInt(result?.resultAmount) ?? fitted.items.length
1186
+ page_num: pageNum,
1187
+ page_size: pageSize,
1188
+ page_amount: pageAmount,
1189
+ result_amount: knownResultAmount
832
1190
  },
833
1191
  items: fitted.items,
834
1192
  applied_limits: {
@@ -836,20 +1194,23 @@ async function executeRecordsList(args) {
836
1194
  row_cap: listLimit.limit,
837
1195
  column_cap: args.max_columns ?? null,
838
1196
  selected_columns: columnProjection.selectedColumns
839
- }
1197
+ },
1198
+ completeness,
1199
+ evidence: buildEvidencePayload(listState, sourcePages)
840
1200
  },
841
- meta: buildMeta(response)
1201
+ meta: responseMeta
842
1202
  };
843
1203
  return {
844
1204
  payload: responsePayload,
845
1205
  message: buildRecordsListMessage({
846
1206
  returned: fitted.items.length,
847
- total: rawItems.length,
1207
+ total: knownResultAmount,
848
1208
  truncationReason
849
1209
  })
850
1210
  };
851
1211
  }
852
1212
  async function executeRecordGet(args) {
1213
+ const queryId = randomUUID();
853
1214
  const response = await client.getRecord(String(args.apply_id));
854
1215
  const record = asObject(response.result) ?? {};
855
1216
  const projection = projectAnswersForOutput({
@@ -872,6 +1233,24 @@ async function executeRecordGet(args) {
872
1233
  applied_limits: {
873
1234
  column_cap: args.max_columns ?? null,
874
1235
  selected_columns: projection.selectedColumns
1236
+ },
1237
+ completeness: {
1238
+ result_amount: 1,
1239
+ returned_items: 1,
1240
+ fetched_pages: 1,
1241
+ requested_pages: 1,
1242
+ actual_scanned_pages: 1,
1243
+ has_more: false,
1244
+ next_page_token: null,
1245
+ is_complete: true,
1246
+ partial: false,
1247
+ omitted_items: 0,
1248
+ omitted_chars: 0
1249
+ },
1250
+ evidence: {
1251
+ query_id: queryId,
1252
+ apply_id: String(args.apply_id),
1253
+ selected_columns: projection.selectedColumns ?? []
875
1254
  }
876
1255
  },
877
1256
  meta: buildMeta(response)
@@ -886,11 +1265,15 @@ async function executeRecordsSummary(args) {
886
1265
  if (!args.select_columns?.length) {
887
1266
  throw new Error("select_columns is required for summary query");
888
1267
  }
1268
+ const queryId = randomUUID();
1269
+ const strictFull = args.strict_full ?? true;
889
1270
  const includeNegative = args.stat_policy?.include_negative ?? true;
890
1271
  const includeNull = args.stat_policy?.include_null ?? false;
891
- const scanMaxPages = args.scan_max_pages ?? 50;
1272
+ const scanMaxPages = args.scan_max_pages ?? DEFAULT_SCAN_MAX_PAGES;
1273
+ const requestedPages = args.requested_pages ?? scanMaxPages;
1274
+ const startPage = resolveStartPage(args.page_num, args.page_token, args.app_key);
892
1275
  const pageSize = args.page_size ?? DEFAULT_PAGE_SIZE;
893
- const rowCap = Math.min(args.max_rows ?? DEFAULT_PAGE_SIZE, 200);
1276
+ const rowCap = Math.min(args.max_rows ?? DEFAULT_ROW_LIMIT, DEFAULT_ROW_LIMIT);
894
1277
  const timezone = args.time_range?.timezone ?? "Asia/Shanghai";
895
1278
  const form = await getFormCached(args.app_key, args.user_id, false);
896
1279
  const index = buildFieldIndex(form.result);
@@ -912,16 +1295,33 @@ async function executeRecordsSummary(args) {
912
1295
  ...(args.time_range.to ? { max_value: args.time_range.to } : {})
913
1296
  });
914
1297
  }
915
- let currentPage = args.page_num ?? 1;
1298
+ const listState = {
1299
+ query_id: queryId,
1300
+ app_key: args.app_key,
1301
+ selected_columns: effectiveColumns.map((item) => item.requested),
1302
+ filters: echoFilters(summaryFilters),
1303
+ time_range: timeColumn
1304
+ ? {
1305
+ column: timeColumn.requested,
1306
+ from: args.time_range?.from ?? null,
1307
+ to: args.time_range?.to ?? null,
1308
+ timezone
1309
+ }
1310
+ : null
1311
+ };
1312
+ let currentPage = startPage;
916
1313
  let scannedPages = 0;
917
1314
  let scannedRecords = 0;
918
- let truncated = false;
1315
+ let hasMore = false;
1316
+ let nextPageNum = null;
1317
+ let resultAmount = null;
919
1318
  let summaryMeta = null;
920
1319
  let totalAmount = 0;
921
1320
  let missingCount = 0;
1321
+ const sourcePages = [];
922
1322
  const rows = [];
923
1323
  const byDay = new Map();
924
- while (true) {
1324
+ while (scannedPages < requestedPages && scannedPages < scanMaxPages) {
925
1325
  const payload = buildListPayload({
926
1326
  pageNum: currentPage,
927
1327
  pageSize,
@@ -936,10 +1336,13 @@ async function executeRecordsSummary(args) {
936
1336
  const response = await client.listRecords(args.app_key, payload, { userId: args.user_id });
937
1337
  summaryMeta = summaryMeta ?? buildMeta(response);
938
1338
  scannedPages += 1;
1339
+ sourcePages.push(currentPage);
939
1340
  const result = asObject(response.result);
940
1341
  const rawItems = asArray(result?.result);
941
1342
  const pageAmount = toPositiveInt(result?.pageAmount);
942
- const hasMoreByAmount = pageAmount !== null ? currentPage < pageAmount : rawItems.length === pageSize;
1343
+ resultAmount = resultAmount ?? toNonNegativeInt(result?.resultAmount);
1344
+ hasMore = pageAmount !== null ? currentPage < pageAmount : rawItems.length === pageSize;
1345
+ nextPageNum = hasMore ? currentPage + 1 : null;
943
1346
  for (const rawItem of rawItems) {
944
1347
  const record = asObject(rawItem) ?? {};
945
1348
  const answers = asArray(record.answers);
@@ -978,14 +1381,10 @@ async function executeRecordsSummary(args) {
978
1381
  }
979
1382
  byDay.set(dayKey, bucket);
980
1383
  }
981
- if (!hasMoreByAmount) {
1384
+ if (!hasMore) {
982
1385
  break;
983
1386
  }
984
- if (scannedPages >= scanMaxPages) {
985
- truncated = true;
986
- break;
987
- }
988
- currentPage += 1;
1387
+ currentPage = currentPage + 1;
989
1388
  }
990
1389
  const byDayStats = Array.from(byDay.entries())
991
1390
  .sort((a, b) => a[0].localeCompare(b[0]))
@@ -1028,6 +1427,37 @@ async function executeRecordsSummary(args) {
1028
1427
  if (!summaryMeta) {
1029
1428
  throw new Error("Failed to build summary metadata");
1030
1429
  }
1430
+ const knownResultAmount = resultAmount ?? scannedRecords;
1431
+ const omittedItems = Math.max(0, knownResultAmount - scannedRecords);
1432
+ const isComplete = !hasMore && omittedItems === 0;
1433
+ const nextPageToken = hasMore && nextPageNum
1434
+ ? encodeContinuationToken({
1435
+ app_key: args.app_key,
1436
+ next_page_num: nextPageNum,
1437
+ page_size: pageSize
1438
+ })
1439
+ : null;
1440
+ const completeness = {
1441
+ result_amount: knownResultAmount,
1442
+ returned_items: scannedRecords,
1443
+ fetched_pages: scannedPages,
1444
+ requested_pages: requestedPages,
1445
+ actual_scanned_pages: scannedPages,
1446
+ has_more: hasMore,
1447
+ next_page_token: nextPageToken,
1448
+ is_complete: isComplete,
1449
+ partial: !isComplete,
1450
+ omitted_items: omittedItems,
1451
+ omitted_chars: 0
1452
+ };
1453
+ const evidence = buildEvidencePayload(listState, sourcePages);
1454
+ if (strictFull && !isComplete) {
1455
+ throw new NeedMoreDataError("Summary is incomplete. Continue with next_page_token or increase requested_pages/scan_max_pages.", {
1456
+ code: "NEED_MORE_DATA",
1457
+ completeness,
1458
+ evidence
1459
+ });
1460
+ }
1031
1461
  return {
1032
1462
  data: {
1033
1463
  summary: {
@@ -1037,6 +1467,8 @@ async function executeRecordsSummary(args) {
1037
1467
  missing_count: missingCount
1038
1468
  },
1039
1469
  rows,
1470
+ completeness,
1471
+ evidence,
1040
1472
  meta: {
1041
1473
  field_mapping: fieldMapping,
1042
1474
  filters: {
@@ -1057,7 +1489,7 @@ async function executeRecordsSummary(args) {
1057
1489
  execution: {
1058
1490
  scanned_records: scannedRecords,
1059
1491
  scanned_pages: scannedPages,
1060
- truncated,
1492
+ truncated: !isComplete,
1061
1493
  row_cap: rowCap,
1062
1494
  column_cap: args.max_columns ?? null,
1063
1495
  scan_max_pages: scanMaxPages
@@ -1065,7 +1497,219 @@ async function executeRecordsSummary(args) {
1065
1497
  }
1066
1498
  },
1067
1499
  meta: summaryMeta,
1068
- message: `Summarized ${scannedRecords} records`
1500
+ message: isComplete
1501
+ ? `Summarized ${scannedRecords} records`
1502
+ : `Summarized ${scannedRecords}/${knownResultAmount} records (partial)`
1503
+ };
1504
+ }
1505
+ async function executeRecordsAggregate(args) {
1506
+ const queryId = randomUUID();
1507
+ const strictFull = args.strict_full ?? true;
1508
+ const includeNegative = args.stat_policy?.include_negative ?? true;
1509
+ const includeNull = args.stat_policy?.include_null ?? false;
1510
+ const pageSize = args.page_size ?? DEFAULT_PAGE_SIZE;
1511
+ const scanMaxPages = args.scan_max_pages ?? DEFAULT_SCAN_MAX_PAGES;
1512
+ const requestedPages = args.requested_pages ?? scanMaxPages;
1513
+ const startPage = resolveStartPage(args.page_num, args.page_token, args.app_key);
1514
+ const maxGroups = args.max_groups ?? 200;
1515
+ const timezone = args.time_range?.timezone ?? "Asia/Shanghai";
1516
+ const form = await getFormCached(args.app_key, args.user_id, false);
1517
+ const index = buildFieldIndex(form.result);
1518
+ const groupColumns = resolveSummaryColumns(args.group_by, index, "group_by");
1519
+ const amountColumn = args.amount_column !== undefined
1520
+ ? resolveSummaryColumn(args.amount_column, index, "amount_column")
1521
+ : null;
1522
+ const timeColumn = args.time_range ? resolveSummaryColumn(args.time_range.column, index, "time_range.column") : null;
1523
+ const normalizedSort = await normalizeListSort(args.sort, args.app_key, args.user_id);
1524
+ const aggregateFilters = [...(args.filters ?? [])];
1525
+ if (timeColumn && (args.time_range?.from || args.time_range?.to)) {
1526
+ aggregateFilters.push({
1527
+ que_id: timeColumn.que_id,
1528
+ ...(args.time_range.from ? { min_value: args.time_range.from } : {}),
1529
+ ...(args.time_range.to ? { max_value: args.time_range.to } : {})
1530
+ });
1531
+ }
1532
+ const listState = {
1533
+ query_id: queryId,
1534
+ app_key: args.app_key,
1535
+ selected_columns: groupColumns.map((item) => item.requested),
1536
+ filters: echoFilters(aggregateFilters),
1537
+ time_range: timeColumn
1538
+ ? {
1539
+ column: timeColumn.requested,
1540
+ from: args.time_range?.from ?? null,
1541
+ to: args.time_range?.to ?? null,
1542
+ timezone
1543
+ }
1544
+ : null
1545
+ };
1546
+ let currentPage = startPage;
1547
+ let scannedPages = 0;
1548
+ let scannedRecords = 0;
1549
+ let hasMore = false;
1550
+ let nextPageNum = null;
1551
+ let resultAmount = null;
1552
+ let responseMeta = null;
1553
+ let totalAmount = 0;
1554
+ const sourcePages = [];
1555
+ const groupStats = new Map();
1556
+ while (scannedPages < requestedPages && scannedPages < scanMaxPages) {
1557
+ const payload = buildListPayload({
1558
+ pageNum: currentPage,
1559
+ pageSize,
1560
+ mode: args.mode,
1561
+ type: args.type,
1562
+ keyword: args.keyword,
1563
+ queryLogic: args.query_logic,
1564
+ applyIds: args.apply_ids,
1565
+ sort: normalizedSort,
1566
+ filters: aggregateFilters
1567
+ });
1568
+ const response = await client.listRecords(args.app_key, payload, { userId: args.user_id });
1569
+ responseMeta = responseMeta ?? buildMeta(response);
1570
+ scannedPages += 1;
1571
+ sourcePages.push(currentPage);
1572
+ const result = asObject(response.result);
1573
+ const rawItems = asArray(result?.result);
1574
+ const pageAmount = toPositiveInt(result?.pageAmount);
1575
+ resultAmount = resultAmount ?? toNonNegativeInt(result?.resultAmount);
1576
+ hasMore = pageAmount !== null ? currentPage < pageAmount : rawItems.length === pageSize;
1577
+ nextPageNum = hasMore ? currentPage + 1 : null;
1578
+ for (const rawItem of rawItems) {
1579
+ const record = asObject(rawItem) ?? {};
1580
+ const answers = asArray(record.answers);
1581
+ scannedRecords += 1;
1582
+ const group = {};
1583
+ for (const column of groupColumns) {
1584
+ group[column.requested] = extractSummaryColumnValue(answers, column);
1585
+ }
1586
+ const groupKey = stableJson(group);
1587
+ const bucket = groupStats.get(groupKey) ?? { group, count: 0, amount: 0 };
1588
+ bucket.count += 1;
1589
+ if (amountColumn) {
1590
+ const amountValue = extractSummaryColumnValue(answers, amountColumn);
1591
+ const numericAmount = toFiniteAmount(amountValue);
1592
+ if (numericAmount === null) {
1593
+ if (includeNull) {
1594
+ // Keep group count while amount contributes 0.
1595
+ }
1596
+ }
1597
+ else if (includeNegative || numericAmount >= 0) {
1598
+ bucket.amount += numericAmount;
1599
+ totalAmount += numericAmount;
1600
+ }
1601
+ }
1602
+ groupStats.set(groupKey, bucket);
1603
+ }
1604
+ if (!hasMore) {
1605
+ break;
1606
+ }
1607
+ currentPage = currentPage + 1;
1608
+ }
1609
+ if (!responseMeta) {
1610
+ throw new Error("Failed to fetch aggregate pages");
1611
+ }
1612
+ const knownResultAmount = resultAmount ?? scannedRecords;
1613
+ const omittedItems = Math.max(0, knownResultAmount - scannedRecords);
1614
+ const isComplete = !hasMore && omittedItems === 0;
1615
+ const nextPageToken = hasMore && nextPageNum
1616
+ ? encodeContinuationToken({
1617
+ app_key: args.app_key,
1618
+ next_page_num: nextPageNum,
1619
+ page_size: pageSize
1620
+ })
1621
+ : null;
1622
+ const completeness = {
1623
+ result_amount: knownResultAmount,
1624
+ returned_items: scannedRecords,
1625
+ fetched_pages: scannedPages,
1626
+ requested_pages: requestedPages,
1627
+ actual_scanned_pages: scannedPages,
1628
+ has_more: hasMore,
1629
+ next_page_token: nextPageToken,
1630
+ is_complete: isComplete,
1631
+ partial: !isComplete,
1632
+ omitted_items: omittedItems,
1633
+ omitted_chars: 0
1634
+ };
1635
+ const evidence = buildEvidencePayload(listState, sourcePages);
1636
+ if (strictFull && !isComplete) {
1637
+ throw new NeedMoreDataError("Aggregate result is incomplete. Continue with next_page_token or increase requested_pages/scan_max_pages.", {
1638
+ code: "NEED_MORE_DATA",
1639
+ completeness,
1640
+ evidence
1641
+ });
1642
+ }
1643
+ const groups = Array.from(groupStats.values())
1644
+ .sort((a, b) => b.count - a.count)
1645
+ .slice(0, maxGroups)
1646
+ .map((bucket) => ({
1647
+ group: bucket.group,
1648
+ count: bucket.count,
1649
+ count_ratio: scannedRecords > 0 ? bucket.count / scannedRecords : 0,
1650
+ amount_total: amountColumn ? bucket.amount : null,
1651
+ amount_ratio: amountColumn && totalAmount !== 0
1652
+ ? bucket.amount / totalAmount
1653
+ : amountColumn
1654
+ ? 0
1655
+ : null
1656
+ }));
1657
+ const fieldMapping = [
1658
+ ...groupColumns.map((item) => ({
1659
+ role: "group_by",
1660
+ requested: item.requested,
1661
+ que_id: item.que_id,
1662
+ que_title: item.que_title,
1663
+ que_type: item.que_type
1664
+ })),
1665
+ ...(amountColumn
1666
+ ? [
1667
+ {
1668
+ role: "amount",
1669
+ requested: amountColumn.requested,
1670
+ que_id: amountColumn.que_id,
1671
+ que_title: amountColumn.que_title,
1672
+ que_type: amountColumn.que_type
1673
+ }
1674
+ ]
1675
+ : []),
1676
+ ...(timeColumn
1677
+ ? [
1678
+ {
1679
+ role: "time",
1680
+ requested: timeColumn.requested,
1681
+ que_id: timeColumn.que_id,
1682
+ que_title: timeColumn.que_title,
1683
+ que_type: timeColumn.que_type
1684
+ }
1685
+ ]
1686
+ : [])
1687
+ ];
1688
+ return {
1689
+ payload: {
1690
+ ok: true,
1691
+ data: {
1692
+ app_key: args.app_key,
1693
+ summary: {
1694
+ total_count: scannedRecords,
1695
+ total_amount: amountColumn ? totalAmount : null
1696
+ },
1697
+ groups,
1698
+ completeness,
1699
+ evidence,
1700
+ meta: {
1701
+ field_mapping: fieldMapping,
1702
+ stat_policy: {
1703
+ include_negative: includeNegative,
1704
+ include_null: includeNull
1705
+ }
1706
+ }
1707
+ },
1708
+ meta: responseMeta
1709
+ },
1710
+ message: isComplete
1711
+ ? `Aggregated ${scannedRecords} records`
1712
+ : `Aggregated ${scannedRecords}/${knownResultAmount} records (partial)`
1069
1713
  };
1070
1714
  }
1071
1715
  function resolveSummaryColumns(columns, index, label) {
@@ -1513,10 +2157,10 @@ function resolveListItemLimit(params) {
1513
2157
  }
1514
2158
  return { limit, reason: null };
1515
2159
  }
1516
- if (params.includeAnswers && params.total > DEFAULT_MAX_ITEMS_WITH_ANSWERS) {
2160
+ if (params.total > DEFAULT_ROW_LIMIT) {
1517
2161
  return {
1518
- limit: DEFAULT_MAX_ITEMS_WITH_ANSWERS,
1519
- reason: `auto-limited to ${DEFAULT_MAX_ITEMS_WITH_ANSWERS} items because include_answers=true`
2162
+ limit: DEFAULT_ROW_LIMIT,
2163
+ reason: `default-limited to ${DEFAULT_ROW_LIMIT} items`
1520
2164
  };
1521
2165
  }
1522
2166
  return { limit: params.total, reason: null };
@@ -1580,17 +2224,21 @@ function projectAnswersForOutput(params) {
1580
2224
  }
1581
2225
  function fitListItemsWithinSize(params) {
1582
2226
  let candidate = params.items;
1583
- let size = jsonSizeBytes(candidate);
2227
+ const originalSize = jsonSizeBytes(candidate);
2228
+ let size = originalSize;
1584
2229
  if (size <= params.limitBytes) {
1585
- return { items: candidate, reason: null };
2230
+ return { items: candidate, reason: null, omittedItems: 0, omittedChars: 0 };
1586
2231
  }
2232
+ const originalCount = candidate.length;
1587
2233
  while (candidate.length > 1) {
1588
2234
  candidate = candidate.slice(0, candidate.length - 1);
1589
2235
  size = jsonSizeBytes(candidate);
1590
2236
  if (size <= params.limitBytes) {
1591
2237
  return {
1592
2238
  items: candidate,
1593
- reason: `auto-limited to ${candidate.length} items to keep response <= ${params.limitBytes} bytes`
2239
+ reason: `auto-limited to ${candidate.length} items to keep response <= ${params.limitBytes} bytes`,
2240
+ omittedItems: Math.max(0, originalCount - candidate.length),
2241
+ omittedChars: Math.max(0, originalSize - size)
1594
2242
  };
1595
2243
  }
1596
2244
  }
@@ -1657,6 +2305,20 @@ function normalizeQueId(queId) {
1657
2305
  }
1658
2306
  throw new Error(`Resolved que_id has unsupported type: ${typeof queId}`);
1659
2307
  }
2308
+ function stableJson(value) {
2309
+ if (value === null || value === undefined) {
2310
+ return "null";
2311
+ }
2312
+ if (typeof value !== "object") {
2313
+ return JSON.stringify(value);
2314
+ }
2315
+ if (Array.isArray(value)) {
2316
+ return `[${value.map((item) => stableJson(item)).join(",")}]`;
2317
+ }
2318
+ const obj = value;
2319
+ const keys = Object.keys(obj).sort();
2320
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(obj[key])}`).join(",")}}`;
2321
+ }
1660
2322
  function jsonSizeBytes(value) {
1661
2323
  return Buffer.byteLength(JSON.stringify(value), "utf8");
1662
2324
  }
@@ -1688,6 +2350,15 @@ function errorResult(error) {
1688
2350
  };
1689
2351
  }
1690
2352
  function toErrorPayload(error) {
2353
+ if (error instanceof NeedMoreDataError) {
2354
+ return {
2355
+ ok: false,
2356
+ code: error.code,
2357
+ status: "need_more_data",
2358
+ message: error.message,
2359
+ details: error.details
2360
+ };
2361
+ }
1691
2362
  if (error instanceof QingflowApiError) {
1692
2363
  return {
1693
2364
  ok: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qingflow-mcp",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "type": "module",