qingflow-mcp 0.2.5 → 0.2.7

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 +29 -0
  2. package/dist/server.js +717 -97
  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,6 +110,28 @@ 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
+ 3. In `list` mode, `time_range` is translated to list filters when `from` or `to` is provided.
123
+
124
+ Summary mode output:
125
+
126
+ 1. `summary`: aggregated stats (`total_count`, `total_amount`, `by_day`, `missing_count`).
127
+ 2. `rows`: strict column rows (only requested `select_columns`).
128
+ 3. `meta`: field mapping, filter scope, stat policy, execution limits.
129
+
130
+ Return shape:
131
+
132
+ 1. success: structured payload `{ "ok": true, "data": ..., "meta": ... }`
133
+ 2. failure: MCP `isError=true`, and text content is JSON payload like `{ "ok": false, "message": ..., ... }`
134
+
106
135
  ## List Query Tips
107
136
 
108
137
  Strict mode (`qf_records_list`):
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.7"
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,6 +148,7 @@ const formOutputSchema = z.object({
147
148
  }),
148
149
  meta: apiMetaSchema
149
150
  });
151
+ const formOutputSchema = formSuccessOutputSchema;
150
152
  const listInputSchema = z
151
153
  .object({
152
154
  app_key: z.string().min(1),
@@ -201,7 +203,7 @@ const listInputSchema = z
201
203
  .refine((value) => value.include_answers !== false, {
202
204
  message: "include_answers=false is not allowed in strict column mode"
203
205
  });
204
- const listOutputSchema = z.object({
206
+ const listSuccessOutputSchema = z.object({
205
207
  ok: z.literal(true),
206
208
  data: z.object({
207
209
  app_key: z.string(),
@@ -223,6 +225,7 @@ const listOutputSchema = z.object({
223
225
  }),
224
226
  meta: apiMetaSchema
225
227
  });
228
+ const listOutputSchema = listSuccessOutputSchema;
226
229
  const recordGetInputSchema = z.object({
227
230
  apply_id: z.union([z.string().min(1), z.number().int()]),
228
231
  max_columns: z.number().int().positive().max(200).optional(),
@@ -232,7 +235,7 @@ const recordGetInputSchema = z.object({
232
235
  .max(200)
233
236
  .optional()
234
237
  });
235
- const recordGetOutputSchema = z.object({
238
+ const recordGetSuccessOutputSchema = z.object({
236
239
  ok: z.literal(true),
237
240
  data: z.object({
238
241
  apply_id: z.union([z.string(), z.number(), z.null()]),
@@ -247,6 +250,7 @@ const recordGetOutputSchema = z.object({
247
250
  }),
248
251
  meta: apiMetaSchema
249
252
  });
253
+ const recordGetOutputSchema = recordGetSuccessOutputSchema;
250
254
  const createInputSchema = z
251
255
  .object({
252
256
  app_key: z.string().min(1),
@@ -266,7 +270,7 @@ const createInputSchema = z
266
270
  .refine((value) => hasWritePayload(value.answers, value.fields), {
267
271
  message: "Either answers or fields is required"
268
272
  });
269
- const createOutputSchema = z.object({
273
+ const createSuccessOutputSchema = z.object({
270
274
  ok: z.literal(true),
271
275
  data: z.object({
272
276
  request_id: z.string().nullable(),
@@ -275,6 +279,7 @@ const createOutputSchema = z.object({
275
279
  }),
276
280
  meta: apiMetaSchema
277
281
  });
282
+ const createOutputSchema = createSuccessOutputSchema;
278
283
  const updateInputSchema = z
279
284
  .object({
280
285
  apply_id: z.union([z.string().min(1), z.number().int()]),
@@ -287,7 +292,7 @@ const updateInputSchema = z
287
292
  .refine((value) => hasWritePayload(value.answers, value.fields), {
288
293
  message: "Either answers or fields is required"
289
294
  });
290
- const updateOutputSchema = z.object({
295
+ const updateSuccessOutputSchema = z.object({
291
296
  ok: z.literal(true),
292
297
  data: z.object({
293
298
  request_id: z.string().nullable(),
@@ -295,14 +300,144 @@ const updateOutputSchema = z.object({
295
300
  }),
296
301
  meta: apiMetaSchema
297
302
  });
303
+ const updateOutputSchema = updateSuccessOutputSchema;
298
304
  const operationInputSchema = z.object({
299
305
  request_id: z.string().min(1)
300
306
  });
301
- const operationOutputSchema = z.object({
307
+ const operationSuccessOutputSchema = z.object({
302
308
  ok: z.literal(true),
303
309
  data: operationResultSchema,
304
310
  meta: apiMetaSchema
305
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;
306
441
  server.registerTool("qf_apps_list", {
307
442
  title: "Qingflow Apps List",
308
443
  description: "List Qingflow apps with optional filtering and client-side slicing.",
@@ -390,73 +525,8 @@ server.registerTool("qf_records_list", {
390
525
  }
391
526
  }, async (args) => {
392
527
  try {
393
- const pageNum = args.page_num ?? 1;
394
- const pageSize = args.page_size ?? DEFAULT_PAGE_SIZE;
395
- const normalizedSort = await normalizeListSort(args.sort, args.app_key, args.user_id);
396
- const includeAnswers = true;
397
- const payload = buildListPayload({
398
- pageNum,
399
- pageSize,
400
- mode: args.mode,
401
- type: args.type,
402
- keyword: args.keyword,
403
- queryLogic: args.query_logic,
404
- applyIds: args.apply_ids,
405
- sort: normalizedSort,
406
- filters: args.filters
407
- });
408
- const response = await client.listRecords(args.app_key, payload, { userId: args.user_id });
409
- const result = asObject(response.result);
410
- const rawItems = asArray(result?.result);
411
- const listLimit = resolveListItemLimit({
412
- total: rawItems.length,
413
- requestedMaxRows: args.max_rows,
414
- requestedMaxItems: args.max_items,
415
- includeAnswers
416
- });
417
- const items = rawItems
418
- .slice(0, listLimit.limit)
419
- .map((raw) => normalizeRecordItem(raw, includeAnswers));
420
- const columnProjection = projectRecordItemsColumns({
421
- items,
422
- includeAnswers,
423
- maxColumns: args.max_columns,
424
- selectColumns: args.select_columns
425
- });
426
- if (items.length > 0 && columnProjection.matchedAnswersCount === 0) {
427
- throw new Error(`No answers matched select_columns (${args.select_columns
428
- .map((item) => String(item))
429
- .join(", ")}). Check que_id/title from qf_form_get.`);
430
- }
431
- const fitted = fitListItemsWithinSize({
432
- items: columnProjection.items,
433
- limitBytes: MAX_LIST_ITEMS_BYTES
434
- });
435
- const truncationReason = mergeTruncationReasons(listLimit.reason, columnProjection.reason, fitted.reason);
436
- return okResult({
437
- ok: true,
438
- data: {
439
- app_key: args.app_key,
440
- pagination: {
441
- page_num: toPositiveInt(result?.pageNum) ?? pageNum,
442
- page_size: toPositiveInt(result?.pageSize) ?? pageSize,
443
- page_amount: toNonNegativeInt(result?.pageAmount),
444
- result_amount: toNonNegativeInt(result?.resultAmount) ?? fitted.items.length
445
- },
446
- items: fitted.items,
447
- applied_limits: {
448
- include_answers: includeAnswers,
449
- row_cap: listLimit.limit,
450
- column_cap: args.max_columns ?? null,
451
- selected_columns: columnProjection.selectedColumns
452
- }
453
- },
454
- meta: buildMeta(response)
455
- }, buildRecordsListMessage({
456
- returned: fitted.items.length,
457
- total: rawItems.length,
458
- truncationReason
459
- }));
528
+ const executed = await executeRecordsList(args);
529
+ return okResult(executed.payload, executed.message);
460
530
  }
461
531
  catch (error) {
462
532
  return errorResult(error);
@@ -473,31 +543,61 @@ server.registerTool("qf_record_get", {
473
543
  }
474
544
  }, async (args) => {
475
545
  try {
476
- const response = await client.getRecord(String(args.apply_id));
477
- const record = asObject(response.result) ?? {};
478
- const projection = projectAnswersForOutput({
479
- answers: asArray(record.answers),
480
- maxColumns: args.max_columns,
481
- selectColumns: args.select_columns
482
- });
483
- const projectedRecord = {
484
- ...record,
485
- answers: projection.answers
486
- };
487
- 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);
488
592
  return okResult({
489
593
  ok: true,
490
594
  data: {
491
- apply_id: record.applyId ?? null,
492
- answer_count: answerCount,
493
- record: projectedRecord,
494
- applied_limits: {
495
- column_cap: args.max_columns ?? null,
496
- selected_columns: projection.selectedColumns
497
- }
595
+ mode: "list",
596
+ source_tool: "qf_records_list",
597
+ list: executed.payload.data
498
598
  },
499
- meta: buildMeta(response)
500
- }, `Fetched record ${String(args.apply_id)}`);
599
+ meta: executed.payload.meta
600
+ }, executed.message);
501
601
  }
502
602
  catch (error) {
503
603
  return errorResult(error);
@@ -624,6 +724,526 @@ function buildMeta(response) {
624
724
  base_url: baseUrl
625
725
  };
626
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
+ const filters = buildListFiltersFromQuery(args);
751
+ return listInputSchema.parse({
752
+ app_key: args.app_key,
753
+ user_id: args.user_id,
754
+ page_num: args.page_num,
755
+ page_size: args.page_size,
756
+ mode: args.mode,
757
+ type: args.type,
758
+ keyword: args.keyword,
759
+ query_logic: args.query_logic,
760
+ apply_ids: args.apply_ids,
761
+ sort: args.sort,
762
+ filters,
763
+ max_rows: args.max_rows,
764
+ max_items: args.max_items,
765
+ max_columns: args.max_columns,
766
+ select_columns: args.select_columns,
767
+ include_answers: args.include_answers
768
+ });
769
+ }
770
+ function buildListFiltersFromQuery(args) {
771
+ const filters = [...(args.filters ?? [])];
772
+ const timeRange = args.time_range;
773
+ if (!timeRange) {
774
+ return filters.length > 0 ? filters : undefined;
775
+ }
776
+ if (timeRange.from === undefined && timeRange.to === undefined) {
777
+ return filters.length > 0 ? filters : undefined;
778
+ }
779
+ const timeSelector = normalizeColumnSelector(timeRange.column);
780
+ const alreadyHasTimeFilter = filters.some((item) => {
781
+ if (item.que_id === undefined) {
782
+ return false;
783
+ }
784
+ return normalizeColumnSelector(item.que_id) === timeSelector;
785
+ });
786
+ if (!alreadyHasTimeFilter) {
787
+ filters.push({
788
+ que_id: timeRange.column,
789
+ ...(timeRange.from !== undefined ? { min_value: timeRange.from } : {}),
790
+ ...(timeRange.to !== undefined ? { max_value: timeRange.to } : {})
791
+ });
792
+ }
793
+ return filters.length > 0 ? filters : undefined;
794
+ }
795
+ function buildRecordGetArgsFromQuery(args) {
796
+ if (args.apply_id === undefined) {
797
+ throw new Error("apply_id is required for record query");
798
+ }
799
+ return recordGetInputSchema.parse({
800
+ apply_id: args.apply_id,
801
+ max_columns: args.max_columns,
802
+ select_columns: args.select_columns
803
+ });
804
+ }
805
+ async function executeRecordsList(args) {
806
+ const pageNum = args.page_num ?? 1;
807
+ const pageSize = args.page_size ?? DEFAULT_PAGE_SIZE;
808
+ const normalizedSort = await normalizeListSort(args.sort, args.app_key, args.user_id);
809
+ const includeAnswers = true;
810
+ const payload = buildListPayload({
811
+ pageNum,
812
+ pageSize,
813
+ mode: args.mode,
814
+ type: args.type,
815
+ keyword: args.keyword,
816
+ queryLogic: args.query_logic,
817
+ applyIds: args.apply_ids,
818
+ sort: normalizedSort,
819
+ filters: args.filters
820
+ });
821
+ const response = await client.listRecords(args.app_key, payload, { userId: args.user_id });
822
+ const result = asObject(response.result);
823
+ const rawItems = asArray(result?.result);
824
+ const listLimit = resolveListItemLimit({
825
+ total: rawItems.length,
826
+ requestedMaxRows: args.max_rows,
827
+ requestedMaxItems: args.max_items,
828
+ includeAnswers
829
+ });
830
+ const items = rawItems
831
+ .slice(0, listLimit.limit)
832
+ .map((raw) => normalizeRecordItem(raw, includeAnswers));
833
+ const columnProjection = projectRecordItemsColumns({
834
+ items,
835
+ includeAnswers,
836
+ maxColumns: args.max_columns,
837
+ selectColumns: args.select_columns
838
+ });
839
+ if (items.length > 0 && columnProjection.matchedAnswersCount === 0) {
840
+ throw new Error(`No answers matched select_columns (${args.select_columns
841
+ .map((item) => String(item))
842
+ .join(", ")}). Check que_id/title from qf_form_get.`);
843
+ }
844
+ const fitted = fitListItemsWithinSize({
845
+ items: columnProjection.items,
846
+ limitBytes: MAX_LIST_ITEMS_BYTES
847
+ });
848
+ const truncationReason = mergeTruncationReasons(listLimit.reason, columnProjection.reason, fitted.reason);
849
+ const responsePayload = {
850
+ ok: true,
851
+ data: {
852
+ app_key: args.app_key,
853
+ pagination: {
854
+ page_num: toPositiveInt(result?.pageNum) ?? pageNum,
855
+ page_size: toPositiveInt(result?.pageSize) ?? pageSize,
856
+ page_amount: toNonNegativeInt(result?.pageAmount),
857
+ result_amount: toNonNegativeInt(result?.resultAmount) ?? fitted.items.length
858
+ },
859
+ items: fitted.items,
860
+ applied_limits: {
861
+ include_answers: includeAnswers,
862
+ row_cap: listLimit.limit,
863
+ column_cap: args.max_columns ?? null,
864
+ selected_columns: columnProjection.selectedColumns
865
+ }
866
+ },
867
+ meta: buildMeta(response)
868
+ };
869
+ return {
870
+ payload: responsePayload,
871
+ message: buildRecordsListMessage({
872
+ returned: fitted.items.length,
873
+ total: rawItems.length,
874
+ truncationReason
875
+ })
876
+ };
877
+ }
878
+ async function executeRecordGet(args) {
879
+ const response = await client.getRecord(String(args.apply_id));
880
+ const record = asObject(response.result) ?? {};
881
+ const projection = projectAnswersForOutput({
882
+ answers: asArray(record.answers),
883
+ maxColumns: args.max_columns,
884
+ selectColumns: args.select_columns
885
+ });
886
+ const projectedRecord = {
887
+ ...record,
888
+ answers: projection.answers
889
+ };
890
+ const answerCount = projection.answers.length;
891
+ return {
892
+ payload: {
893
+ ok: true,
894
+ data: {
895
+ apply_id: record.applyId ?? null,
896
+ answer_count: answerCount,
897
+ record: projectedRecord,
898
+ applied_limits: {
899
+ column_cap: args.max_columns ?? null,
900
+ selected_columns: projection.selectedColumns
901
+ }
902
+ },
903
+ meta: buildMeta(response)
904
+ },
905
+ message: `Fetched record ${String(args.apply_id)}`
906
+ };
907
+ }
908
+ async function executeRecordsSummary(args) {
909
+ if (!args.app_key) {
910
+ throw new Error("app_key is required for summary query");
911
+ }
912
+ if (!args.select_columns?.length) {
913
+ throw new Error("select_columns is required for summary query");
914
+ }
915
+ const includeNegative = args.stat_policy?.include_negative ?? true;
916
+ const includeNull = args.stat_policy?.include_null ?? false;
917
+ const scanMaxPages = args.scan_max_pages ?? 50;
918
+ const pageSize = args.page_size ?? DEFAULT_PAGE_SIZE;
919
+ const rowCap = Math.min(args.max_rows ?? DEFAULT_PAGE_SIZE, 200);
920
+ const timezone = args.time_range?.timezone ?? "Asia/Shanghai";
921
+ const form = await getFormCached(args.app_key, args.user_id, false);
922
+ const index = buildFieldIndex(form.result);
923
+ const selectedColumns = resolveSummaryColumns(args.select_columns, index, "select_columns");
924
+ const effectiveColumns = args.max_columns !== undefined ? selectedColumns.slice(0, args.max_columns) : selectedColumns;
925
+ if (effectiveColumns.length === 0) {
926
+ throw new Error("No output columns remain after max_columns cap");
927
+ }
928
+ const amountColumn = args.amount_column !== undefined
929
+ ? resolveSummaryColumn(args.amount_column, index, "amount_column")
930
+ : null;
931
+ const timeColumn = args.time_range ? resolveSummaryColumn(args.time_range.column, index, "time_range.column") : null;
932
+ const normalizedSort = await normalizeListSort(args.sort, args.app_key, args.user_id);
933
+ const summaryFilters = [...(args.filters ?? [])];
934
+ if (timeColumn && (args.time_range?.from || args.time_range?.to)) {
935
+ summaryFilters.push({
936
+ que_id: timeColumn.que_id,
937
+ ...(args.time_range.from ? { min_value: args.time_range.from } : {}),
938
+ ...(args.time_range.to ? { max_value: args.time_range.to } : {})
939
+ });
940
+ }
941
+ let currentPage = args.page_num ?? 1;
942
+ let scannedPages = 0;
943
+ let scannedRecords = 0;
944
+ let truncated = false;
945
+ let summaryMeta = null;
946
+ let totalAmount = 0;
947
+ let missingCount = 0;
948
+ const rows = [];
949
+ const byDay = new Map();
950
+ while (true) {
951
+ const payload = buildListPayload({
952
+ pageNum: currentPage,
953
+ pageSize,
954
+ mode: args.mode,
955
+ type: args.type,
956
+ keyword: args.keyword,
957
+ queryLogic: args.query_logic,
958
+ applyIds: args.apply_ids,
959
+ sort: normalizedSort,
960
+ filters: summaryFilters
961
+ });
962
+ const response = await client.listRecords(args.app_key, payload, { userId: args.user_id });
963
+ summaryMeta = summaryMeta ?? buildMeta(response);
964
+ scannedPages += 1;
965
+ const result = asObject(response.result);
966
+ const rawItems = asArray(result?.result);
967
+ const pageAmount = toPositiveInt(result?.pageAmount);
968
+ const hasMoreByAmount = pageAmount !== null ? currentPage < pageAmount : rawItems.length === pageSize;
969
+ for (const rawItem of rawItems) {
970
+ const record = asObject(rawItem) ?? {};
971
+ const answers = asArray(record.answers);
972
+ scannedRecords += 1;
973
+ if (rows.length < rowCap) {
974
+ rows.push(buildSummaryRow(answers, effectiveColumns));
975
+ }
976
+ let amountContribution = 0;
977
+ let hasAmountContribution = false;
978
+ if (amountColumn) {
979
+ const amountValue = extractSummaryColumnValue(answers, amountColumn);
980
+ const numericAmount = toFiniteAmount(amountValue);
981
+ if (numericAmount === null) {
982
+ if (!includeNull) {
983
+ missingCount += 1;
984
+ }
985
+ else {
986
+ hasAmountContribution = true;
987
+ }
988
+ }
989
+ else if (includeNegative || numericAmount >= 0) {
990
+ amountContribution = numericAmount;
991
+ hasAmountContribution = true;
992
+ }
993
+ }
994
+ if (hasAmountContribution) {
995
+ totalAmount += amountContribution;
996
+ }
997
+ const dayKey = timeColumn
998
+ ? toDayBucket(extractSummaryColumnValue(answers, timeColumn), timezone)
999
+ : "all";
1000
+ const bucket = byDay.get(dayKey) ?? { count: 0, amount: 0 };
1001
+ bucket.count += 1;
1002
+ if (amountColumn && hasAmountContribution) {
1003
+ bucket.amount += amountContribution;
1004
+ }
1005
+ byDay.set(dayKey, bucket);
1006
+ }
1007
+ if (!hasMoreByAmount) {
1008
+ break;
1009
+ }
1010
+ if (scannedPages >= scanMaxPages) {
1011
+ truncated = true;
1012
+ break;
1013
+ }
1014
+ currentPage += 1;
1015
+ }
1016
+ const byDayStats = Array.from(byDay.entries())
1017
+ .sort((a, b) => a[0].localeCompare(b[0]))
1018
+ .map(([day, bucket]) => ({
1019
+ day,
1020
+ count: bucket.count,
1021
+ amount_total: amountColumn ? bucket.amount : null
1022
+ }));
1023
+ const fieldMapping = [
1024
+ ...effectiveColumns.map((item) => ({
1025
+ role: "row",
1026
+ requested: item.requested,
1027
+ que_id: item.que_id,
1028
+ que_title: item.que_title,
1029
+ que_type: item.que_type
1030
+ })),
1031
+ ...(amountColumn
1032
+ ? [
1033
+ {
1034
+ role: "amount",
1035
+ requested: amountColumn.requested,
1036
+ que_id: amountColumn.que_id,
1037
+ que_title: amountColumn.que_title,
1038
+ que_type: amountColumn.que_type
1039
+ }
1040
+ ]
1041
+ : []),
1042
+ ...(timeColumn
1043
+ ? [
1044
+ {
1045
+ role: "time",
1046
+ requested: timeColumn.requested,
1047
+ que_id: timeColumn.que_id,
1048
+ que_title: timeColumn.que_title,
1049
+ que_type: timeColumn.que_type
1050
+ }
1051
+ ]
1052
+ : [])
1053
+ ];
1054
+ if (!summaryMeta) {
1055
+ throw new Error("Failed to build summary metadata");
1056
+ }
1057
+ return {
1058
+ data: {
1059
+ summary: {
1060
+ total_count: scannedRecords,
1061
+ total_amount: amountColumn ? totalAmount : null,
1062
+ by_day: byDayStats,
1063
+ missing_count: missingCount
1064
+ },
1065
+ rows,
1066
+ meta: {
1067
+ field_mapping: fieldMapping,
1068
+ filters: {
1069
+ app_key: args.app_key,
1070
+ time_range: timeColumn
1071
+ ? {
1072
+ column: timeColumn.requested,
1073
+ from: args.time_range?.from ?? null,
1074
+ to: args.time_range?.to ?? null,
1075
+ timezone
1076
+ }
1077
+ : null
1078
+ },
1079
+ stat_policy: {
1080
+ include_negative: includeNegative,
1081
+ include_null: includeNull
1082
+ },
1083
+ execution: {
1084
+ scanned_records: scannedRecords,
1085
+ scanned_pages: scannedPages,
1086
+ truncated,
1087
+ row_cap: rowCap,
1088
+ column_cap: args.max_columns ?? null,
1089
+ scan_max_pages: scanMaxPages
1090
+ }
1091
+ }
1092
+ },
1093
+ meta: summaryMeta,
1094
+ message: `Summarized ${scannedRecords} records`
1095
+ };
1096
+ }
1097
+ function resolveSummaryColumns(columns, index, label) {
1098
+ return normalizeColumnSelectors(columns).map((requested) => resolveSummaryColumn(requested, index, label));
1099
+ }
1100
+ function resolveSummaryColumn(column, index, label) {
1101
+ const requested = String(column).trim();
1102
+ if (!requested) {
1103
+ throw new Error(`${label} contains an empty column selector`);
1104
+ }
1105
+ if (isNumericKey(requested)) {
1106
+ const hit = index.byId.get(String(Number(requested)));
1107
+ if (!hit) {
1108
+ throw new Error(`${label} references unknown que_id "${requested}"`);
1109
+ }
1110
+ return {
1111
+ requested,
1112
+ que_id: normalizeQueId(hit.queId),
1113
+ que_title: asNullableString(hit.queTitle),
1114
+ que_type: hit.queType
1115
+ };
1116
+ }
1117
+ const hit = resolveFieldByKey(requested, index);
1118
+ if (!hit || hit.queId === undefined || hit.queId === null) {
1119
+ throw new Error(`${label} cannot resolve field "${requested}"`);
1120
+ }
1121
+ return {
1122
+ requested,
1123
+ que_id: normalizeQueId(hit.queId),
1124
+ que_title: asNullableString(hit.queTitle),
1125
+ que_type: hit.queType
1126
+ };
1127
+ }
1128
+ function buildSummaryRow(answers, columns) {
1129
+ const row = {};
1130
+ for (const column of columns) {
1131
+ row[column.requested] = extractSummaryColumnValue(answers, column);
1132
+ }
1133
+ return row;
1134
+ }
1135
+ function extractSummaryColumnValue(answers, column) {
1136
+ const targetId = normalizeColumnSelector(String(column.que_id));
1137
+ const targetTitle = column.que_title ? normalizeColumnSelector(column.que_title) : null;
1138
+ for (const answerRaw of answers) {
1139
+ const answer = asObject(answerRaw);
1140
+ if (!answer) {
1141
+ continue;
1142
+ }
1143
+ const answerQueId = asNullableString(answer.queId);
1144
+ if (answerQueId && normalizeColumnSelector(answerQueId) === targetId) {
1145
+ return extractAnswerDisplayValue(answer);
1146
+ }
1147
+ if (targetTitle) {
1148
+ const answerQueTitle = asNullableString(answer.queTitle);
1149
+ if (answerQueTitle && normalizeColumnSelector(answerQueTitle) === targetTitle) {
1150
+ return extractAnswerDisplayValue(answer);
1151
+ }
1152
+ }
1153
+ }
1154
+ return null;
1155
+ }
1156
+ function extractAnswerDisplayValue(answer) {
1157
+ const tableValues = answer.tableValues ?? answer.table_values;
1158
+ if (tableValues !== undefined) {
1159
+ return tableValues;
1160
+ }
1161
+ const values = asArray(answer.values);
1162
+ if (values.length === 0) {
1163
+ return null;
1164
+ }
1165
+ const normalized = values.map((item) => extractAnswerValueCell(item));
1166
+ return normalized.length === 1 ? normalized[0] : normalized;
1167
+ }
1168
+ function extractAnswerValueCell(value) {
1169
+ const obj = asObject(value);
1170
+ if (!obj) {
1171
+ return value;
1172
+ }
1173
+ if (obj.dataValue !== undefined) {
1174
+ return obj.dataValue;
1175
+ }
1176
+ if (obj.value !== undefined) {
1177
+ return obj.value;
1178
+ }
1179
+ if (obj.valueStr !== undefined) {
1180
+ return obj.valueStr;
1181
+ }
1182
+ return obj;
1183
+ }
1184
+ function toFiniteAmount(value) {
1185
+ if (value === null || value === undefined) {
1186
+ return null;
1187
+ }
1188
+ if (Array.isArray(value)) {
1189
+ if (value.length !== 1) {
1190
+ return null;
1191
+ }
1192
+ return toFiniteAmount(value[0]);
1193
+ }
1194
+ if (typeof value === "number" && Number.isFinite(value)) {
1195
+ return value;
1196
+ }
1197
+ if (typeof value === "string") {
1198
+ const normalized = value.replace(/,/g, "").trim();
1199
+ if (!normalized) {
1200
+ return null;
1201
+ }
1202
+ const parsed = Number(normalized);
1203
+ if (Number.isFinite(parsed)) {
1204
+ return parsed;
1205
+ }
1206
+ }
1207
+ return null;
1208
+ }
1209
+ function toDayBucket(value, timezone) {
1210
+ const first = Array.isArray(value) ? value[0] : value;
1211
+ if (first === null || first === undefined) {
1212
+ return "unknown";
1213
+ }
1214
+ if (typeof first === "string") {
1215
+ const trimmed = first.trim();
1216
+ const direct = trimmed.match(/^(\d{4}-\d{2}-\d{2})/);
1217
+ if (direct) {
1218
+ return direct[1];
1219
+ }
1220
+ const parsed = new Date(trimmed);
1221
+ if (!Number.isNaN(parsed.getTime())) {
1222
+ return formatDateBucket(parsed, timezone);
1223
+ }
1224
+ return "unknown";
1225
+ }
1226
+ if (typeof first === "number") {
1227
+ const parsed = new Date(first);
1228
+ if (!Number.isNaN(parsed.getTime())) {
1229
+ return formatDateBucket(parsed, timezone);
1230
+ }
1231
+ }
1232
+ return "unknown";
1233
+ }
1234
+ function formatDateBucket(value, timezone) {
1235
+ try {
1236
+ return new Intl.DateTimeFormat("en-CA", {
1237
+ timeZone: timezone,
1238
+ year: "numeric",
1239
+ month: "2-digit",
1240
+ day: "2-digit"
1241
+ }).format(value);
1242
+ }
1243
+ catch {
1244
+ return value.toISOString().slice(0, 10);
1245
+ }
1246
+ }
627
1247
  function buildListPayload(params) {
628
1248
  const payload = {
629
1249
  pageNum: params.pageNum,
@@ -1084,7 +1704,7 @@ function errorResult(error) {
1084
1704
  const payload = toErrorPayload(error);
1085
1705
  return {
1086
1706
  isError: true,
1087
- structuredContent: payload,
1707
+ // Keep error payload in text to avoid outputSchema(success) validation conflicts across MCP clients.
1088
1708
  content: [
1089
1709
  {
1090
1710
  type: "text",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qingflow-mcp",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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"