qingflow-mcp 0.2.4 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +34 -0
  2. package/dist/server.js +708 -101
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -6,6 +6,7 @@ This MCP server wraps Qingflow OpenAPI for:
6
6
  - `qf_form_get`
7
7
  - `qf_records_list`
8
8
  - `qf_record_get`
9
+ - `qf_query` (unified read entry: list / record / summary)
9
10
  - `qf_record_create`
10
11
  - `qf_record_update`
11
12
  - `qf_operation_get`
@@ -52,6 +53,12 @@ npm run build
52
53
  npm start
53
54
  ```
54
55
 
56
+ Run tests:
57
+
58
+ ```bash
59
+ npm test
60
+ ```
61
+
55
62
  ## CLI Install
56
63
 
57
64
  Global install from GitHub:
@@ -103,8 +110,35 @@ MCP client config example:
103
110
  3. `qf_record_create` or `qf_record_update`.
104
111
  4. If create/update returns only `request_id`, call `qf_operation_get` to resolve async result.
105
112
 
113
+ ## Unified Query (`qf_query`)
114
+
115
+ `qf_query` is the recommended read entry for agents.
116
+
117
+ 1. `query_mode=auto`:
118
+ - if `apply_id` is set, route to single-record query.
119
+ - if summary params are set (`amount_column` / `time_range` / `stat_policy` / `scan_max_pages`), route to summary query.
120
+ - otherwise route to list query.
121
+ 2. `query_mode=list|record|summary` forces explicit behavior.
122
+
123
+ Summary mode output:
124
+
125
+ 1. `summary`: aggregated stats (`total_count`, `total_amount`, `by_day`, `missing_count`).
126
+ 2. `rows`: strict column rows (only requested `select_columns`).
127
+ 3. `meta`: field mapping, filter scope, stat policy, execution limits.
128
+
129
+ Return shape:
130
+
131
+ 1. success: structured payload `{ "ok": true, "data": ..., "meta": ... }`
132
+ 2. failure: MCP `isError=true`, and text content is JSON payload like `{ "ok": false, "message": ..., ... }`
133
+
106
134
  ## List Query Tips
107
135
 
136
+ Strict mode (`qf_records_list`):
137
+
138
+ 1. `select_columns` is required.
139
+ 2. `include_answers=false` is not allowed.
140
+ 3. Output `items[].answers` contains only selected columns, not full answers.
141
+
108
142
  1. For `qf_records_list.sort[].que_id`, use a real field `que_id` (numeric) or exact field title from `qf_form_get`.
109
143
  2. Avoid aliases like `create_time`; Qingflow often rejects them.
110
144
  3. Use `max_rows` (or `max_items`) to cap returned rows.
package/dist/server.js CHANGED
@@ -36,7 +36,7 @@ const client = new QingflowClient({
36
36
  });
37
37
  const server = new McpServer({
38
38
  name: "qingflow-mcp",
39
- version: "0.2.0"
39
+ version: "0.2.6"
40
40
  });
41
41
  const jsonPrimitiveSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
42
42
  const answerValueSchema = z.union([
@@ -120,7 +120,7 @@ const appsInputSchema = z.object({
120
120
  limit: z.number().int().positive().max(500).optional(),
121
121
  offset: z.number().int().nonnegative().optional()
122
122
  });
123
- const appsOutputSchema = z.object({
123
+ const appsSuccessOutputSchema = z.object({
124
124
  ok: z.literal(true),
125
125
  data: z.object({
126
126
  total_apps: z.number().int().nonnegative(),
@@ -131,13 +131,14 @@ const appsOutputSchema = z.object({
131
131
  }),
132
132
  meta: apiMetaSchema
133
133
  });
134
+ const appsOutputSchema = appsSuccessOutputSchema;
134
135
  const formInputSchema = z.object({
135
136
  app_key: z.string().min(1),
136
137
  user_id: z.string().min(1).optional(),
137
138
  force_refresh: z.boolean().optional(),
138
139
  include_raw: z.boolean().optional()
139
140
  });
140
- const formOutputSchema = z.object({
141
+ const formSuccessOutputSchema = z.object({
141
142
  ok: z.literal(true),
142
143
  data: z.object({
143
144
  app_key: z.string(),
@@ -147,7 +148,9 @@ const formOutputSchema = z.object({
147
148
  }),
148
149
  meta: apiMetaSchema
149
150
  });
150
- const listInputSchema = z.object({
151
+ const formOutputSchema = formSuccessOutputSchema;
152
+ const listInputSchema = z
153
+ .object({
151
154
  app_key: z.string().min(1),
152
155
  user_id: z.string().min(1).optional(),
153
156
  page_num: z.number().int().positive().optional(),
@@ -193,14 +196,14 @@ const listInputSchema = z.object({
193
196
  max_rows: z.number().int().positive().max(200).optional(),
194
197
  max_items: z.number().int().positive().max(200).optional(),
195
198
  max_columns: z.number().int().positive().max(200).optional(),
196
- select_columns: z
197
- .array(z.union([z.string().min(1), z.number().int()]))
198
- .min(1)
199
- .max(200)
200
- .optional(),
199
+ // 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
201
  include_answers: z.boolean().optional()
202
+ })
203
+ .refine((value) => value.include_answers !== false, {
204
+ message: "include_answers=false is not allowed in strict column mode"
202
205
  });
203
- const listOutputSchema = z.object({
206
+ const listSuccessOutputSchema = z.object({
204
207
  ok: z.literal(true),
205
208
  data: z.object({
206
209
  app_key: z.string(),
@@ -216,12 +219,13 @@ const listOutputSchema = z.object({
216
219
  include_answers: z.boolean(),
217
220
  row_cap: z.number().int().nonnegative(),
218
221
  column_cap: z.number().int().positive().nullable(),
219
- selected_columns: z.array(z.string()).nullable()
222
+ selected_columns: z.array(z.string())
220
223
  })
221
224
  .optional()
222
225
  }),
223
226
  meta: apiMetaSchema
224
227
  });
228
+ const listOutputSchema = listSuccessOutputSchema;
225
229
  const recordGetInputSchema = z.object({
226
230
  apply_id: z.union([z.string().min(1), z.number().int()]),
227
231
  max_columns: z.number().int().positive().max(200).optional(),
@@ -231,7 +235,7 @@ const recordGetInputSchema = z.object({
231
235
  .max(200)
232
236
  .optional()
233
237
  });
234
- const recordGetOutputSchema = z.object({
238
+ const recordGetSuccessOutputSchema = z.object({
235
239
  ok: z.literal(true),
236
240
  data: z.object({
237
241
  apply_id: z.union([z.string(), z.number(), z.null()]),
@@ -246,6 +250,7 @@ const recordGetOutputSchema = z.object({
246
250
  }),
247
251
  meta: apiMetaSchema
248
252
  });
253
+ const recordGetOutputSchema = recordGetSuccessOutputSchema;
249
254
  const createInputSchema = z
250
255
  .object({
251
256
  app_key: z.string().min(1),
@@ -265,7 +270,7 @@ const createInputSchema = z
265
270
  .refine((value) => hasWritePayload(value.answers, value.fields), {
266
271
  message: "Either answers or fields is required"
267
272
  });
268
- const createOutputSchema = z.object({
273
+ const createSuccessOutputSchema = z.object({
269
274
  ok: z.literal(true),
270
275
  data: z.object({
271
276
  request_id: z.string().nullable(),
@@ -274,6 +279,7 @@ const createOutputSchema = z.object({
274
279
  }),
275
280
  meta: apiMetaSchema
276
281
  });
282
+ const createOutputSchema = createSuccessOutputSchema;
277
283
  const updateInputSchema = z
278
284
  .object({
279
285
  apply_id: z.union([z.string().min(1), z.number().int()]),
@@ -286,7 +292,7 @@ const updateInputSchema = z
286
292
  .refine((value) => hasWritePayload(value.answers, value.fields), {
287
293
  message: "Either answers or fields is required"
288
294
  });
289
- const updateOutputSchema = z.object({
295
+ const updateSuccessOutputSchema = z.object({
290
296
  ok: z.literal(true),
291
297
  data: z.object({
292
298
  request_id: z.string().nullable(),
@@ -294,14 +300,144 @@ const updateOutputSchema = z.object({
294
300
  }),
295
301
  meta: apiMetaSchema
296
302
  });
303
+ const updateOutputSchema = updateSuccessOutputSchema;
297
304
  const operationInputSchema = z.object({
298
305
  request_id: z.string().min(1)
299
306
  });
300
- const operationOutputSchema = z.object({
307
+ const operationSuccessOutputSchema = z.object({
301
308
  ok: z.literal(true),
302
309
  data: operationResultSchema,
303
310
  meta: apiMetaSchema
304
311
  });
312
+ const operationOutputSchema = operationSuccessOutputSchema;
313
+ const queryInputSchema = z.object({
314
+ query_mode: z.enum(["auto", "list", "record", "summary"]).optional(),
315
+ app_key: z.string().min(1).optional(),
316
+ apply_id: z.union([z.string().min(1), z.number().int()]).optional(),
317
+ user_id: z.string().min(1).optional(),
318
+ page_num: z.number().int().positive().optional(),
319
+ page_size: z.number().int().positive().max(200).optional(),
320
+ mode: z
321
+ .enum([
322
+ "todo",
323
+ "done",
324
+ "mine_approved",
325
+ "mine_rejected",
326
+ "mine_draft",
327
+ "mine_need_improve",
328
+ "mine_processing",
329
+ "all",
330
+ "all_approved",
331
+ "all_rejected",
332
+ "all_processing",
333
+ "cc"
334
+ ])
335
+ .optional(),
336
+ type: z.number().int().min(1).max(12).optional(),
337
+ keyword: z.string().optional(),
338
+ query_logic: z.enum(["and", "or"]).optional(),
339
+ apply_ids: z.array(z.union([z.string(), z.number()])).optional(),
340
+ sort: z
341
+ .array(z.object({
342
+ que_id: z.union([z.string().min(1), z.number().int()]),
343
+ ascend: z.boolean().optional()
344
+ }))
345
+ .optional(),
346
+ filters: z
347
+ .array(z.object({
348
+ que_id: z.union([z.string().min(1), z.number().int()]).optional(),
349
+ search_key: z.string().optional(),
350
+ search_keys: z.array(z.string()).optional(),
351
+ min_value: z.string().optional(),
352
+ max_value: z.string().optional(),
353
+ scope: z.number().int().optional(),
354
+ search_options: z.array(z.union([z.string(), z.number()])).optional(),
355
+ search_user_ids: z.array(z.string()).optional()
356
+ }))
357
+ .optional(),
358
+ max_rows: z.number().int().positive().max(200).optional(),
359
+ max_items: z.number().int().positive().max(200).optional(),
360
+ max_columns: z.number().int().positive().max(200).optional(),
361
+ select_columns: z
362
+ .array(z.union([z.string().min(1), z.number().int()]))
363
+ .min(1)
364
+ .max(200)
365
+ .optional(),
366
+ include_answers: z.boolean().optional(),
367
+ amount_column: z.union([z.string().min(1), z.number().int()]).optional(),
368
+ time_range: z
369
+ .object({
370
+ column: z.union([z.string().min(1), z.number().int()]),
371
+ from: z.string().optional(),
372
+ to: z.string().optional(),
373
+ timezone: z.string().optional()
374
+ })
375
+ .optional(),
376
+ stat_policy: z
377
+ .object({
378
+ include_negative: z.boolean().optional(),
379
+ include_null: z.boolean().optional()
380
+ })
381
+ .optional(),
382
+ scan_max_pages: z.number().int().positive().max(500).optional()
383
+ });
384
+ const querySummaryOutputSchema = z.object({
385
+ summary: z.object({
386
+ total_count: z.number().int().nonnegative(),
387
+ total_amount: z.number().nullable(),
388
+ by_day: z.array(z.object({
389
+ day: z.string(),
390
+ count: z.number().int().nonnegative(),
391
+ amount_total: z.number().nullable()
392
+ })),
393
+ missing_count: z.number().int().nonnegative()
394
+ }),
395
+ rows: z.array(z.record(z.unknown())),
396
+ meta: z.object({
397
+ field_mapping: z.array(z.object({
398
+ role: z.enum(["row", "amount", "time"]),
399
+ requested: z.string(),
400
+ que_id: z.union([z.string(), z.number()]),
401
+ que_title: z.string().nullable(),
402
+ que_type: z.unknown()
403
+ })),
404
+ filters: z.object({
405
+ app_key: z.string(),
406
+ time_range: z
407
+ .object({
408
+ column: z.string(),
409
+ from: z.string().nullable(),
410
+ to: z.string().nullable(),
411
+ timezone: z.string()
412
+ })
413
+ .nullable()
414
+ }),
415
+ stat_policy: z.object({
416
+ include_negative: z.boolean(),
417
+ include_null: z.boolean()
418
+ }),
419
+ execution: z.object({
420
+ scanned_records: z.number().int().nonnegative(),
421
+ scanned_pages: z.number().int().nonnegative(),
422
+ truncated: z.boolean(),
423
+ row_cap: z.number().int().positive(),
424
+ column_cap: z.number().int().positive().nullable(),
425
+ scan_max_pages: z.number().int().positive()
426
+ })
427
+ })
428
+ });
429
+ const querySuccessOutputSchema = z.object({
430
+ ok: z.literal(true),
431
+ data: z.object({
432
+ mode: z.enum(["list", "record", "summary"]),
433
+ source_tool: z.enum(["qf_records_list", "qf_record_get", "qf_records_summary"]),
434
+ list: listSuccessOutputSchema.shape.data.optional(),
435
+ record: recordGetSuccessOutputSchema.shape.data.optional(),
436
+ summary: querySummaryOutputSchema.optional()
437
+ }),
438
+ meta: apiMetaSchema
439
+ });
440
+ const queryOutputSchema = querySuccessOutputSchema;
305
441
  server.registerTool("qf_apps_list", {
306
442
  title: "Qingflow Apps List",
307
443
  description: "List Qingflow apps with optional filtering and client-side slicing.",
@@ -389,68 +525,8 @@ server.registerTool("qf_records_list", {
389
525
  }
390
526
  }, async (args) => {
391
527
  try {
392
- const pageNum = args.page_num ?? 1;
393
- const pageSize = args.page_size ?? DEFAULT_PAGE_SIZE;
394
- const normalizedSort = await normalizeListSort(args.sort, args.app_key, args.user_id);
395
- const includeAnswers = Boolean(args.include_answers || (args.select_columns && args.select_columns.length > 0));
396
- const payload = buildListPayload({
397
- pageNum,
398
- pageSize,
399
- mode: args.mode,
400
- type: args.type,
401
- keyword: args.keyword,
402
- queryLogic: args.query_logic,
403
- applyIds: args.apply_ids,
404
- sort: normalizedSort,
405
- filters: args.filters
406
- });
407
- const response = await client.listRecords(args.app_key, payload, { userId: args.user_id });
408
- const result = asObject(response.result);
409
- const rawItems = asArray(result?.result);
410
- const listLimit = resolveListItemLimit({
411
- total: rawItems.length,
412
- requestedMaxRows: args.max_rows,
413
- requestedMaxItems: args.max_items,
414
- includeAnswers
415
- });
416
- const items = rawItems
417
- .slice(0, listLimit.limit)
418
- .map((raw) => normalizeRecordItem(raw, includeAnswers));
419
- const columnProjection = projectRecordItemsColumns({
420
- items,
421
- includeAnswers,
422
- maxColumns: args.max_columns,
423
- selectColumns: args.select_columns
424
- });
425
- const fitted = fitListItemsWithinSize({
426
- items: columnProjection.items,
427
- limitBytes: MAX_LIST_ITEMS_BYTES
428
- });
429
- const truncationReason = mergeTruncationReasons(listLimit.reason, columnProjection.reason, fitted.reason);
430
- return okResult({
431
- ok: true,
432
- data: {
433
- app_key: args.app_key,
434
- pagination: {
435
- page_num: toPositiveInt(result?.pageNum) ?? pageNum,
436
- page_size: toPositiveInt(result?.pageSize) ?? pageSize,
437
- page_amount: toNonNegativeInt(result?.pageAmount),
438
- result_amount: toNonNegativeInt(result?.resultAmount) ?? fitted.items.length
439
- },
440
- items: fitted.items,
441
- applied_limits: {
442
- include_answers: includeAnswers,
443
- row_cap: listLimit.limit,
444
- column_cap: args.max_columns ?? null,
445
- selected_columns: columnProjection.selectedColumns
446
- }
447
- },
448
- meta: buildMeta(response)
449
- }, buildRecordsListMessage({
450
- returned: fitted.items.length,
451
- total: rawItems.length,
452
- truncationReason
453
- }));
528
+ const executed = await executeRecordsList(args);
529
+ return okResult(executed.payload, executed.message);
454
530
  }
455
531
  catch (error) {
456
532
  return errorResult(error);
@@ -467,31 +543,61 @@ server.registerTool("qf_record_get", {
467
543
  }
468
544
  }, async (args) => {
469
545
  try {
470
- const response = await client.getRecord(String(args.apply_id));
471
- const record = asObject(response.result) ?? {};
472
- const projection = projectAnswersForOutput({
473
- answers: asArray(record.answers),
474
- maxColumns: args.max_columns,
475
- selectColumns: args.select_columns
476
- });
477
- const projectedRecord = {
478
- ...record,
479
- answers: projection.answers
480
- };
481
- const answerCount = projection.answers.length;
546
+ const executed = await executeRecordGet(args);
547
+ return okResult(executed.payload, executed.message);
548
+ }
549
+ catch (error) {
550
+ return errorResult(error);
551
+ }
552
+ });
553
+ server.registerTool("qf_query", {
554
+ title: "Qingflow Unified Query",
555
+ description: "Unified read entry for list/record/summary. Use query_mode=auto to route automatically.",
556
+ inputSchema: queryInputSchema,
557
+ outputSchema: queryOutputSchema,
558
+ annotations: {
559
+ readOnlyHint: true,
560
+ idempotentHint: true
561
+ }
562
+ }, async (args) => {
563
+ try {
564
+ const routedMode = resolveQueryMode(args);
565
+ if (routedMode === "record") {
566
+ const recordArgs = buildRecordGetArgsFromQuery(args);
567
+ const executed = await executeRecordGet(recordArgs);
568
+ return okResult({
569
+ ok: true,
570
+ data: {
571
+ mode: "record",
572
+ source_tool: "qf_record_get",
573
+ record: executed.payload.data
574
+ },
575
+ meta: executed.payload.meta
576
+ }, executed.message);
577
+ }
578
+ if (routedMode === "summary") {
579
+ const executed = await executeRecordsSummary(args);
580
+ return okResult({
581
+ ok: true,
582
+ data: {
583
+ mode: "summary",
584
+ source_tool: "qf_records_summary",
585
+ summary: executed.data
586
+ },
587
+ meta: executed.meta
588
+ }, executed.message);
589
+ }
590
+ const listArgs = buildListArgsFromQuery(args);
591
+ const executed = await executeRecordsList(listArgs);
482
592
  return okResult({
483
593
  ok: true,
484
594
  data: {
485
- apply_id: record.applyId ?? null,
486
- answer_count: answerCount,
487
- record: projectedRecord,
488
- applied_limits: {
489
- column_cap: args.max_columns ?? null,
490
- selected_columns: projection.selectedColumns
491
- }
595
+ mode: "list",
596
+ source_tool: "qf_records_list",
597
+ list: executed.payload.data
492
598
  },
493
- meta: buildMeta(response)
494
- }, `Fetched record ${String(args.apply_id)}`);
599
+ meta: executed.payload.meta
600
+ }, executed.message);
495
601
  }
496
602
  catch (error) {
497
603
  return errorResult(error);
@@ -618,6 +724,500 @@ function buildMeta(response) {
618
724
  base_url: baseUrl
619
725
  };
620
726
  }
727
+ function resolveQueryMode(args) {
728
+ const requested = args.query_mode ?? "auto";
729
+ if (requested !== "auto") {
730
+ return requested;
731
+ }
732
+ if (args.apply_id !== undefined) {
733
+ return "record";
734
+ }
735
+ if (args.amount_column !== undefined ||
736
+ args.time_range !== undefined ||
737
+ args.stat_policy !== undefined ||
738
+ args.scan_max_pages !== undefined) {
739
+ return "summary";
740
+ }
741
+ return "list";
742
+ }
743
+ function buildListArgsFromQuery(args) {
744
+ if (!args.app_key) {
745
+ throw new Error("app_key is required for list query");
746
+ }
747
+ if (!args.select_columns?.length) {
748
+ throw new Error("select_columns is required for list query");
749
+ }
750
+ return listInputSchema.parse({
751
+ app_key: args.app_key,
752
+ user_id: args.user_id,
753
+ page_num: args.page_num,
754
+ page_size: args.page_size,
755
+ mode: args.mode,
756
+ type: args.type,
757
+ keyword: args.keyword,
758
+ query_logic: args.query_logic,
759
+ apply_ids: args.apply_ids,
760
+ sort: args.sort,
761
+ filters: args.filters,
762
+ max_rows: args.max_rows,
763
+ max_items: args.max_items,
764
+ max_columns: args.max_columns,
765
+ select_columns: args.select_columns,
766
+ include_answers: args.include_answers
767
+ });
768
+ }
769
+ function buildRecordGetArgsFromQuery(args) {
770
+ if (args.apply_id === undefined) {
771
+ throw new Error("apply_id is required for record query");
772
+ }
773
+ return recordGetInputSchema.parse({
774
+ apply_id: args.apply_id,
775
+ max_columns: args.max_columns,
776
+ select_columns: args.select_columns
777
+ });
778
+ }
779
+ async function executeRecordsList(args) {
780
+ const pageNum = args.page_num ?? 1;
781
+ const pageSize = args.page_size ?? DEFAULT_PAGE_SIZE;
782
+ const normalizedSort = await normalizeListSort(args.sort, args.app_key, args.user_id);
783
+ 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);
798
+ const listLimit = resolveListItemLimit({
799
+ total: rawItems.length,
800
+ requestedMaxRows: args.max_rows,
801
+ requestedMaxItems: args.max_items,
802
+ includeAnswers
803
+ });
804
+ const items = rawItems
805
+ .slice(0, listLimit.limit)
806
+ .map((raw) => normalizeRecordItem(raw, includeAnswers));
807
+ const columnProjection = projectRecordItemsColumns({
808
+ items,
809
+ includeAnswers,
810
+ maxColumns: args.max_columns,
811
+ selectColumns: args.select_columns
812
+ });
813
+ if (items.length > 0 && columnProjection.matchedAnswersCount === 0) {
814
+ throw new Error(`No answers matched select_columns (${args.select_columns
815
+ .map((item) => String(item))
816
+ .join(", ")}). Check que_id/title from qf_form_get.`);
817
+ }
818
+ const fitted = fitListItemsWithinSize({
819
+ items: columnProjection.items,
820
+ limitBytes: MAX_LIST_ITEMS_BYTES
821
+ });
822
+ const truncationReason = mergeTruncationReasons(listLimit.reason, columnProjection.reason, fitted.reason);
823
+ const responsePayload = {
824
+ ok: true,
825
+ data: {
826
+ app_key: args.app_key,
827
+ 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
832
+ },
833
+ items: fitted.items,
834
+ applied_limits: {
835
+ include_answers: includeAnswers,
836
+ row_cap: listLimit.limit,
837
+ column_cap: args.max_columns ?? null,
838
+ selected_columns: columnProjection.selectedColumns
839
+ }
840
+ },
841
+ meta: buildMeta(response)
842
+ };
843
+ return {
844
+ payload: responsePayload,
845
+ message: buildRecordsListMessage({
846
+ returned: fitted.items.length,
847
+ total: rawItems.length,
848
+ truncationReason
849
+ })
850
+ };
851
+ }
852
+ async function executeRecordGet(args) {
853
+ const response = await client.getRecord(String(args.apply_id));
854
+ const record = asObject(response.result) ?? {};
855
+ const projection = projectAnswersForOutput({
856
+ answers: asArray(record.answers),
857
+ maxColumns: args.max_columns,
858
+ selectColumns: args.select_columns
859
+ });
860
+ const projectedRecord = {
861
+ ...record,
862
+ answers: projection.answers
863
+ };
864
+ const answerCount = projection.answers.length;
865
+ return {
866
+ payload: {
867
+ ok: true,
868
+ data: {
869
+ apply_id: record.applyId ?? null,
870
+ answer_count: answerCount,
871
+ record: projectedRecord,
872
+ applied_limits: {
873
+ column_cap: args.max_columns ?? null,
874
+ selected_columns: projection.selectedColumns
875
+ }
876
+ },
877
+ meta: buildMeta(response)
878
+ },
879
+ message: `Fetched record ${String(args.apply_id)}`
880
+ };
881
+ }
882
+ async function executeRecordsSummary(args) {
883
+ if (!args.app_key) {
884
+ throw new Error("app_key is required for summary query");
885
+ }
886
+ if (!args.select_columns?.length) {
887
+ throw new Error("select_columns is required for summary query");
888
+ }
889
+ const includeNegative = args.stat_policy?.include_negative ?? true;
890
+ const includeNull = args.stat_policy?.include_null ?? false;
891
+ const scanMaxPages = args.scan_max_pages ?? 50;
892
+ const pageSize = args.page_size ?? DEFAULT_PAGE_SIZE;
893
+ const rowCap = Math.min(args.max_rows ?? DEFAULT_PAGE_SIZE, 200);
894
+ const timezone = args.time_range?.timezone ?? "Asia/Shanghai";
895
+ const form = await getFormCached(args.app_key, args.user_id, false);
896
+ const index = buildFieldIndex(form.result);
897
+ const selectedColumns = resolveSummaryColumns(args.select_columns, index, "select_columns");
898
+ const effectiveColumns = args.max_columns !== undefined ? selectedColumns.slice(0, args.max_columns) : selectedColumns;
899
+ if (effectiveColumns.length === 0) {
900
+ throw new Error("No output columns remain after max_columns cap");
901
+ }
902
+ const amountColumn = args.amount_column !== undefined
903
+ ? resolveSummaryColumn(args.amount_column, index, "amount_column")
904
+ : null;
905
+ const timeColumn = args.time_range ? resolveSummaryColumn(args.time_range.column, index, "time_range.column") : null;
906
+ const normalizedSort = await normalizeListSort(args.sort, args.app_key, args.user_id);
907
+ const summaryFilters = [...(args.filters ?? [])];
908
+ if (timeColumn && (args.time_range?.from || args.time_range?.to)) {
909
+ summaryFilters.push({
910
+ que_id: timeColumn.que_id,
911
+ ...(args.time_range.from ? { min_value: args.time_range.from } : {}),
912
+ ...(args.time_range.to ? { max_value: args.time_range.to } : {})
913
+ });
914
+ }
915
+ let currentPage = args.page_num ?? 1;
916
+ let scannedPages = 0;
917
+ let scannedRecords = 0;
918
+ let truncated = false;
919
+ let summaryMeta = null;
920
+ let totalAmount = 0;
921
+ let missingCount = 0;
922
+ const rows = [];
923
+ const byDay = new Map();
924
+ while (true) {
925
+ const payload = buildListPayload({
926
+ pageNum: currentPage,
927
+ pageSize,
928
+ mode: args.mode,
929
+ type: args.type,
930
+ keyword: args.keyword,
931
+ queryLogic: args.query_logic,
932
+ applyIds: args.apply_ids,
933
+ sort: normalizedSort,
934
+ filters: summaryFilters
935
+ });
936
+ const response = await client.listRecords(args.app_key, payload, { userId: args.user_id });
937
+ summaryMeta = summaryMeta ?? buildMeta(response);
938
+ scannedPages += 1;
939
+ const result = asObject(response.result);
940
+ const rawItems = asArray(result?.result);
941
+ const pageAmount = toPositiveInt(result?.pageAmount);
942
+ const hasMoreByAmount = pageAmount !== null ? currentPage < pageAmount : rawItems.length === pageSize;
943
+ for (const rawItem of rawItems) {
944
+ const record = asObject(rawItem) ?? {};
945
+ const answers = asArray(record.answers);
946
+ scannedRecords += 1;
947
+ if (rows.length < rowCap) {
948
+ rows.push(buildSummaryRow(answers, effectiveColumns));
949
+ }
950
+ let amountContribution = 0;
951
+ let hasAmountContribution = false;
952
+ if (amountColumn) {
953
+ const amountValue = extractSummaryColumnValue(answers, amountColumn);
954
+ const numericAmount = toFiniteAmount(amountValue);
955
+ if (numericAmount === null) {
956
+ if (!includeNull) {
957
+ missingCount += 1;
958
+ }
959
+ else {
960
+ hasAmountContribution = true;
961
+ }
962
+ }
963
+ else if (includeNegative || numericAmount >= 0) {
964
+ amountContribution = numericAmount;
965
+ hasAmountContribution = true;
966
+ }
967
+ }
968
+ if (hasAmountContribution) {
969
+ totalAmount += amountContribution;
970
+ }
971
+ const dayKey = timeColumn
972
+ ? toDayBucket(extractSummaryColumnValue(answers, timeColumn), timezone)
973
+ : "all";
974
+ const bucket = byDay.get(dayKey) ?? { count: 0, amount: 0 };
975
+ bucket.count += 1;
976
+ if (amountColumn && hasAmountContribution) {
977
+ bucket.amount += amountContribution;
978
+ }
979
+ byDay.set(dayKey, bucket);
980
+ }
981
+ if (!hasMoreByAmount) {
982
+ break;
983
+ }
984
+ if (scannedPages >= scanMaxPages) {
985
+ truncated = true;
986
+ break;
987
+ }
988
+ currentPage += 1;
989
+ }
990
+ const byDayStats = Array.from(byDay.entries())
991
+ .sort((a, b) => a[0].localeCompare(b[0]))
992
+ .map(([day, bucket]) => ({
993
+ day,
994
+ count: bucket.count,
995
+ amount_total: amountColumn ? bucket.amount : null
996
+ }));
997
+ const fieldMapping = [
998
+ ...effectiveColumns.map((item) => ({
999
+ role: "row",
1000
+ requested: item.requested,
1001
+ que_id: item.que_id,
1002
+ que_title: item.que_title,
1003
+ que_type: item.que_type
1004
+ })),
1005
+ ...(amountColumn
1006
+ ? [
1007
+ {
1008
+ role: "amount",
1009
+ requested: amountColumn.requested,
1010
+ que_id: amountColumn.que_id,
1011
+ que_title: amountColumn.que_title,
1012
+ que_type: amountColumn.que_type
1013
+ }
1014
+ ]
1015
+ : []),
1016
+ ...(timeColumn
1017
+ ? [
1018
+ {
1019
+ role: "time",
1020
+ requested: timeColumn.requested,
1021
+ que_id: timeColumn.que_id,
1022
+ que_title: timeColumn.que_title,
1023
+ que_type: timeColumn.que_type
1024
+ }
1025
+ ]
1026
+ : [])
1027
+ ];
1028
+ if (!summaryMeta) {
1029
+ throw new Error("Failed to build summary metadata");
1030
+ }
1031
+ return {
1032
+ data: {
1033
+ summary: {
1034
+ total_count: scannedRecords,
1035
+ total_amount: amountColumn ? totalAmount : null,
1036
+ by_day: byDayStats,
1037
+ missing_count: missingCount
1038
+ },
1039
+ rows,
1040
+ meta: {
1041
+ field_mapping: fieldMapping,
1042
+ filters: {
1043
+ app_key: args.app_key,
1044
+ time_range: timeColumn
1045
+ ? {
1046
+ column: timeColumn.requested,
1047
+ from: args.time_range?.from ?? null,
1048
+ to: args.time_range?.to ?? null,
1049
+ timezone
1050
+ }
1051
+ : null
1052
+ },
1053
+ stat_policy: {
1054
+ include_negative: includeNegative,
1055
+ include_null: includeNull
1056
+ },
1057
+ execution: {
1058
+ scanned_records: scannedRecords,
1059
+ scanned_pages: scannedPages,
1060
+ truncated,
1061
+ row_cap: rowCap,
1062
+ column_cap: args.max_columns ?? null,
1063
+ scan_max_pages: scanMaxPages
1064
+ }
1065
+ }
1066
+ },
1067
+ meta: summaryMeta,
1068
+ message: `Summarized ${scannedRecords} records`
1069
+ };
1070
+ }
1071
+ function resolveSummaryColumns(columns, index, label) {
1072
+ return normalizeColumnSelectors(columns).map((requested) => resolveSummaryColumn(requested, index, label));
1073
+ }
1074
+ function resolveSummaryColumn(column, index, label) {
1075
+ const requested = String(column).trim();
1076
+ if (!requested) {
1077
+ throw new Error(`${label} contains an empty column selector`);
1078
+ }
1079
+ if (isNumericKey(requested)) {
1080
+ const hit = index.byId.get(String(Number(requested)));
1081
+ if (!hit) {
1082
+ throw new Error(`${label} references unknown que_id "${requested}"`);
1083
+ }
1084
+ return {
1085
+ requested,
1086
+ que_id: normalizeQueId(hit.queId),
1087
+ que_title: asNullableString(hit.queTitle),
1088
+ que_type: hit.queType
1089
+ };
1090
+ }
1091
+ const hit = resolveFieldByKey(requested, index);
1092
+ if (!hit || hit.queId === undefined || hit.queId === null) {
1093
+ throw new Error(`${label} cannot resolve field "${requested}"`);
1094
+ }
1095
+ return {
1096
+ requested,
1097
+ que_id: normalizeQueId(hit.queId),
1098
+ que_title: asNullableString(hit.queTitle),
1099
+ que_type: hit.queType
1100
+ };
1101
+ }
1102
+ function buildSummaryRow(answers, columns) {
1103
+ const row = {};
1104
+ for (const column of columns) {
1105
+ row[column.requested] = extractSummaryColumnValue(answers, column);
1106
+ }
1107
+ return row;
1108
+ }
1109
+ function extractSummaryColumnValue(answers, column) {
1110
+ const targetId = normalizeColumnSelector(String(column.que_id));
1111
+ const targetTitle = column.que_title ? normalizeColumnSelector(column.que_title) : null;
1112
+ for (const answerRaw of answers) {
1113
+ const answer = asObject(answerRaw);
1114
+ if (!answer) {
1115
+ continue;
1116
+ }
1117
+ const answerQueId = asNullableString(answer.queId);
1118
+ if (answerQueId && normalizeColumnSelector(answerQueId) === targetId) {
1119
+ return extractAnswerDisplayValue(answer);
1120
+ }
1121
+ if (targetTitle) {
1122
+ const answerQueTitle = asNullableString(answer.queTitle);
1123
+ if (answerQueTitle && normalizeColumnSelector(answerQueTitle) === targetTitle) {
1124
+ return extractAnswerDisplayValue(answer);
1125
+ }
1126
+ }
1127
+ }
1128
+ return null;
1129
+ }
1130
+ function extractAnswerDisplayValue(answer) {
1131
+ const tableValues = answer.tableValues ?? answer.table_values;
1132
+ if (tableValues !== undefined) {
1133
+ return tableValues;
1134
+ }
1135
+ const values = asArray(answer.values);
1136
+ if (values.length === 0) {
1137
+ return null;
1138
+ }
1139
+ const normalized = values.map((item) => extractAnswerValueCell(item));
1140
+ return normalized.length === 1 ? normalized[0] : normalized;
1141
+ }
1142
+ function extractAnswerValueCell(value) {
1143
+ const obj = asObject(value);
1144
+ if (!obj) {
1145
+ return value;
1146
+ }
1147
+ if (obj.dataValue !== undefined) {
1148
+ return obj.dataValue;
1149
+ }
1150
+ if (obj.value !== undefined) {
1151
+ return obj.value;
1152
+ }
1153
+ if (obj.valueStr !== undefined) {
1154
+ return obj.valueStr;
1155
+ }
1156
+ return obj;
1157
+ }
1158
+ function toFiniteAmount(value) {
1159
+ if (value === null || value === undefined) {
1160
+ return null;
1161
+ }
1162
+ if (Array.isArray(value)) {
1163
+ if (value.length !== 1) {
1164
+ return null;
1165
+ }
1166
+ return toFiniteAmount(value[0]);
1167
+ }
1168
+ if (typeof value === "number" && Number.isFinite(value)) {
1169
+ return value;
1170
+ }
1171
+ if (typeof value === "string") {
1172
+ const normalized = value.replace(/,/g, "").trim();
1173
+ if (!normalized) {
1174
+ return null;
1175
+ }
1176
+ const parsed = Number(normalized);
1177
+ if (Number.isFinite(parsed)) {
1178
+ return parsed;
1179
+ }
1180
+ }
1181
+ return null;
1182
+ }
1183
+ function toDayBucket(value, timezone) {
1184
+ const first = Array.isArray(value) ? value[0] : value;
1185
+ if (first === null || first === undefined) {
1186
+ return "unknown";
1187
+ }
1188
+ if (typeof first === "string") {
1189
+ const trimmed = first.trim();
1190
+ const direct = trimmed.match(/^(\d{4}-\d{2}-\d{2})/);
1191
+ if (direct) {
1192
+ return direct[1];
1193
+ }
1194
+ const parsed = new Date(trimmed);
1195
+ if (!Number.isNaN(parsed.getTime())) {
1196
+ return formatDateBucket(parsed, timezone);
1197
+ }
1198
+ return "unknown";
1199
+ }
1200
+ if (typeof first === "number") {
1201
+ const parsed = new Date(first);
1202
+ if (!Number.isNaN(parsed.getTime())) {
1203
+ return formatDateBucket(parsed, timezone);
1204
+ }
1205
+ }
1206
+ return "unknown";
1207
+ }
1208
+ function formatDateBucket(value, timezone) {
1209
+ try {
1210
+ return new Intl.DateTimeFormat("en-CA", {
1211
+ timeZone: timezone,
1212
+ year: "numeric",
1213
+ month: "2-digit",
1214
+ day: "2-digit"
1215
+ }).format(value);
1216
+ }
1217
+ catch {
1218
+ return value.toISOString().slice(0, 10);
1219
+ }
1220
+ }
621
1221
  function buildListPayload(params) {
622
1222
  const payload = {
623
1223
  pageNum: params.pageNum,
@@ -926,12 +1526,17 @@ function projectRecordItemsColumns(params) {
926
1526
  return {
927
1527
  items: params.items,
928
1528
  reason: null,
929
- selectedColumns: null
1529
+ selectedColumns: [],
1530
+ matchedAnswersCount: 0
930
1531
  };
931
1532
  }
932
1533
  const normalizedSelectors = normalizeColumnSelectors(params.selectColumns);
1534
+ if (normalizedSelectors.length === 0) {
1535
+ throw new Error("select_columns must contain at least one non-empty column identifier");
1536
+ }
933
1537
  const selectorSet = new Set(normalizedSelectors.map((item) => normalizeColumnSelector(item)));
934
1538
  let columnCapped = false;
1539
+ let matchedAnswersCount = 0;
935
1540
  const projectedItems = params.items.map((item) => {
936
1541
  const answers = asArray(item.answers);
937
1542
  let projected = answers;
@@ -942,6 +1547,7 @@ function projectRecordItemsColumns(params) {
942
1547
  projected = projected.slice(0, params.maxColumns);
943
1548
  columnCapped = true;
944
1549
  }
1550
+ matchedAnswersCount += projected.length;
945
1551
  return {
946
1552
  ...item,
947
1553
  answers: projected
@@ -953,7 +1559,8 @@ function projectRecordItemsColumns(params) {
953
1559
  return {
954
1560
  items: projectedItems,
955
1561
  reason,
956
- selectedColumns: normalizedSelectors.length > 0 ? normalizedSelectors : null
1562
+ selectedColumns: normalizedSelectors,
1563
+ matchedAnswersCount
957
1564
  };
958
1565
  }
959
1566
  function projectAnswersForOutput(params) {
@@ -1071,7 +1678,7 @@ function errorResult(error) {
1071
1678
  const payload = toErrorPayload(error);
1072
1679
  return {
1073
1680
  isError: true,
1074
- structuredContent: payload,
1681
+ // Keep error payload in text to avoid outputSchema(success) validation conflicts across MCP clients.
1075
1682
  content: [
1076
1683
  {
1077
1684
  type: "text",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qingflow-mcp",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,6 +31,7 @@
31
31
  ],
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
34
+ "test": "npm run build && node --test \"test/*.test.js\"",
34
35
  "dev": "tsx src/server.ts",
35
36
  "start": "node dist/server.js",
36
37
  "prepublishOnly": "npm run build"