qingflow-mcp 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,6 +14,10 @@ It intentionally excludes delete for now.
14
14
 
15
15
  ## Setup
16
16
 
17
+ Runtime requirement:
18
+
19
+ - Node.js `>=18`
20
+
17
21
  1. Install dependencies:
18
22
 
19
23
  ```bash
@@ -56,6 +60,12 @@ Global install from GitHub:
56
60
  npm i -g git+https://github.com/853046310/qingflow-mcp.git
57
61
  ```
58
62
 
63
+ Install latest from npm:
64
+
65
+ ```bash
66
+ npm i -g qingflow-mcp@latest
67
+ ```
68
+
59
69
  Or one-click installer:
60
70
 
61
71
  ```bash
@@ -93,6 +103,37 @@ MCP client config example:
93
103
  3. `qf_record_create` or `qf_record_update`.
94
104
  4. If create/update returns only `request_id`, call `qf_operation_get` to resolve async result.
95
105
 
106
+ ## List Query Tips
107
+
108
+ 1. For `qf_records_list.sort[].que_id`, use a real field `que_id` (numeric) or exact field title from `qf_form_get`.
109
+ 2. Avoid aliases like `create_time`; Qingflow often rejects them.
110
+ 3. When `include_answers=true`, the server auto-limits returned items to protect MCP context size.
111
+ 4. You can override item count with `max_items` in `qf_records_list`.
112
+
113
+ Optional env vars:
114
+
115
+ ```bash
116
+ export QINGFLOW_LIST_MAX_ITEMS_WITH_ANSWERS=5
117
+ export QINGFLOW_LIST_MAX_ITEMS_BYTES=400000
118
+ ```
119
+
120
+ ## Troubleshooting
121
+
122
+ If you see runtime errors around `Headers` or missing web APIs:
123
+
124
+ 1. Upgrade Node to `>=18`.
125
+ 2. Upgrade package to latest:
126
+
127
+ ```bash
128
+ npm i -g qingflow-mcp@latest
129
+ ```
130
+
131
+ 3. Verify runtime:
132
+
133
+ ```bash
134
+ node -e "console.log(process.version, typeof fetch, typeof Headers)"
135
+ ```
136
+
96
137
  ## Publish
97
138
 
98
139
  ```bash
@@ -88,24 +88,25 @@ export class QingflowClient {
88
88
  const options = params.options ?? {};
89
89
  const url = new URL(params.path, this.baseUrl);
90
90
  appendQuery(url, options.query);
91
- const headers = new Headers();
92
- headers.set("accessToken", this.accessToken);
91
+ const headers = {
92
+ accessToken: this.accessToken
93
+ };
93
94
  if (options.userId) {
94
- headers.set("userId", options.userId);
95
+ headers.userId = options.userId;
95
96
  }
96
97
  const init = {
97
98
  method: params.method,
98
99
  headers
99
100
  };
100
101
  if (options.body !== undefined) {
101
- headers.set("content-type", "application/json");
102
+ headers["content-type"] = "application/json";
102
103
  init.body = JSON.stringify(options.body);
103
104
  }
104
105
  const controller = new AbortController();
105
106
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
106
107
  init.signal = controller.signal;
107
108
  try {
108
- const response = await fetch(url, init);
109
+ const response = await getFetch()(url, init);
109
110
  const text = await response.text();
110
111
  const data = safeJsonParse(text);
111
112
  if (!response.ok) {
@@ -253,3 +254,12 @@ function toFiniteNumber(value) {
253
254
  }
254
255
  return null;
255
256
  }
257
+ function getFetch() {
258
+ const runtimeFetch = globalThis.fetch;
259
+ if (typeof runtimeFetch === "function") {
260
+ return runtimeFetch.bind(globalThis);
261
+ }
262
+ throw new QingflowApiError({
263
+ message: "Global fetch is not available. Use Node.js >= 18."
264
+ });
265
+ }
package/dist/server.js CHANGED
@@ -19,6 +19,9 @@ const MODE_TO_TYPE = {
19
19
  };
20
20
  const FORM_CACHE_TTL_MS = Number(process.env.QINGFLOW_FORM_CACHE_TTL_MS ?? "300000");
21
21
  const formCache = new Map();
22
+ const DEFAULT_PAGE_SIZE = 50;
23
+ const DEFAULT_MAX_ITEMS_WITH_ANSWERS = toPositiveInt(process.env.QINGFLOW_LIST_MAX_ITEMS_WITH_ANSWERS) ?? 5;
24
+ const MAX_LIST_ITEMS_BYTES = toPositiveInt(process.env.QINGFLOW_LIST_MAX_ITEMS_BYTES) ?? 400000;
22
25
  const accessToken = process.env.QINGFLOW_ACCESS_TOKEN;
23
26
  const baseUrl = process.env.QINGFLOW_BASE_URL;
24
27
  if (!accessToken) {
@@ -187,6 +190,7 @@ const listInputSchema = z.object({
187
190
  search_user_ids: z.array(z.string()).optional()
188
191
  }))
189
192
  .optional(),
193
+ max_items: z.number().int().positive().max(200).optional(),
190
194
  include_answers: z.boolean().optional()
191
195
  });
192
196
  const listOutputSchema = z.object({
@@ -358,36 +362,55 @@ server.registerTool("qf_records_list", {
358
362
  }
359
363
  }, async (args) => {
360
364
  try {
365
+ const pageNum = args.page_num ?? 1;
366
+ const pageSize = args.page_size ?? DEFAULT_PAGE_SIZE;
367
+ const normalizedSort = await normalizeListSort(args.sort, args.app_key, args.user_id);
361
368
  const payload = buildListPayload({
362
- pageNum: args.page_num ?? 1,
363
- pageSize: args.page_size ?? 50,
369
+ pageNum,
370
+ pageSize,
364
371
  mode: args.mode,
365
372
  type: args.type,
366
373
  keyword: args.keyword,
367
374
  queryLogic: args.query_logic,
368
375
  applyIds: args.apply_ids,
369
- sort: args.sort,
376
+ sort: normalizedSort,
370
377
  filters: args.filters
371
378
  });
372
379
  const response = await client.listRecords(args.app_key, payload, { userId: args.user_id });
373
380
  const result = asObject(response.result);
374
381
  const rawItems = asArray(result?.result);
375
382
  const includeAnswers = Boolean(args.include_answers);
376
- const items = rawItems.map((raw) => normalizeRecordItem(raw, includeAnswers));
383
+ const listLimit = resolveListItemLimit({
384
+ total: rawItems.length,
385
+ requestedMaxItems: args.max_items,
386
+ includeAnswers
387
+ });
388
+ const items = rawItems
389
+ .slice(0, listLimit.limit)
390
+ .map((raw) => normalizeRecordItem(raw, includeAnswers));
391
+ const fitted = fitListItemsWithinSize({
392
+ items,
393
+ limitBytes: MAX_LIST_ITEMS_BYTES
394
+ });
395
+ const truncationReason = mergeTruncationReasons(listLimit.reason, fitted.reason);
377
396
  return okResult({
378
397
  ok: true,
379
398
  data: {
380
399
  app_key: args.app_key,
381
400
  pagination: {
382
- page_num: toPositiveInt(result?.pageNum) ?? (args.page_num ?? 1),
383
- page_size: toPositiveInt(result?.pageSize) ?? (args.page_size ?? 50),
401
+ page_num: toPositiveInt(result?.pageNum) ?? pageNum,
402
+ page_size: toPositiveInt(result?.pageSize) ?? pageSize,
384
403
  page_amount: toNonNegativeInt(result?.pageAmount),
385
- result_amount: toNonNegativeInt(result?.resultAmount) ?? items.length
404
+ result_amount: toNonNegativeInt(result?.resultAmount) ?? fitted.items.length
386
405
  },
387
- items
406
+ items: fitted.items
388
407
  },
389
408
  meta: buildMeta(response)
390
- }, `Fetched ${items.length} records`);
409
+ }, buildRecordsListMessage({
410
+ returned: fitted.items.length,
411
+ total: rawItems.length,
412
+ truncationReason
413
+ }));
391
414
  }
392
415
  catch (error) {
393
416
  return errorResult(error);
@@ -789,6 +812,97 @@ function resolveFieldByKey(fieldKey, index) {
789
812
  }
790
813
  return null;
791
814
  }
815
+ async function normalizeListSort(sort, appKey, userId) {
816
+ if (!sort?.length) {
817
+ return sort;
818
+ }
819
+ // Fast path for numeric que_id, which can be passed through directly.
820
+ if (sort.every((item) => isNumericKey(String(item.que_id)))) {
821
+ return sort.map((item) => ({
822
+ que_id: Number(item.que_id),
823
+ ...(item.ascend !== undefined ? { ascend: item.ascend } : {})
824
+ }));
825
+ }
826
+ const form = await getFormCached(appKey, userId, false);
827
+ const index = buildFieldIndex(form.result);
828
+ return sort.map((item) => {
829
+ const rawKey = String(item.que_id).trim();
830
+ const resolved = resolveFieldByKey(rawKey, index);
831
+ if (!resolved || resolved.queId === undefined || resolved.queId === null) {
832
+ throw new Error(`Cannot resolve sort.que_id "${rawKey}". Use numeric que_id or exact field title from qf_form_get.`);
833
+ }
834
+ return {
835
+ que_id: normalizeQueId(resolved.queId),
836
+ ...(item.ascend !== undefined ? { ascend: item.ascend } : {})
837
+ };
838
+ });
839
+ }
840
+ function resolveListItemLimit(params) {
841
+ if (params.total <= 0) {
842
+ return { limit: 0, reason: null };
843
+ }
844
+ if (params.requestedMaxItems !== undefined) {
845
+ const limit = Math.min(params.total, params.requestedMaxItems);
846
+ if (limit < params.total) {
847
+ return {
848
+ limit,
849
+ reason: `limited by max_items=${params.requestedMaxItems}`
850
+ };
851
+ }
852
+ return { limit, reason: null };
853
+ }
854
+ if (params.includeAnswers && params.total > DEFAULT_MAX_ITEMS_WITH_ANSWERS) {
855
+ return {
856
+ limit: DEFAULT_MAX_ITEMS_WITH_ANSWERS,
857
+ reason: `auto-limited to ${DEFAULT_MAX_ITEMS_WITH_ANSWERS} items because include_answers=true`
858
+ };
859
+ }
860
+ return { limit: params.total, reason: null };
861
+ }
862
+ function fitListItemsWithinSize(params) {
863
+ let candidate = params.items;
864
+ let size = jsonSizeBytes(candidate);
865
+ if (size <= params.limitBytes) {
866
+ return { items: candidate, reason: null };
867
+ }
868
+ while (candidate.length > 1) {
869
+ candidate = candidate.slice(0, candidate.length - 1);
870
+ size = jsonSizeBytes(candidate);
871
+ if (size <= params.limitBytes) {
872
+ return {
873
+ items: candidate,
874
+ reason: `auto-limited to ${candidate.length} items to keep response <= ${params.limitBytes} bytes`
875
+ };
876
+ }
877
+ }
878
+ throw new Error(`qf_records_list response is too large (${size} bytes > ${params.limitBytes}) even with 1 item. Use qf_record_get(apply_id).`);
879
+ }
880
+ function mergeTruncationReasons(...reasons) {
881
+ const list = reasons.filter((item) => Boolean(item));
882
+ return list.length > 0 ? list.join("; ") : null;
883
+ }
884
+ function buildRecordsListMessage(params) {
885
+ if (!params.truncationReason) {
886
+ return `Fetched ${params.returned} records`;
887
+ }
888
+ return `Fetched ${params.returned}/${params.total} records (${params.truncationReason})`;
889
+ }
890
+ function normalizeQueId(queId) {
891
+ if (typeof queId === "number" && Number.isInteger(queId)) {
892
+ return queId;
893
+ }
894
+ if (typeof queId === "string") {
895
+ const normalized = queId.trim();
896
+ if (!normalized) {
897
+ throw new Error("Resolved que_id is empty");
898
+ }
899
+ return isNumericKey(normalized) ? Number(normalized) : normalized;
900
+ }
901
+ throw new Error(`Resolved que_id has unsupported type: ${typeof queId}`);
902
+ }
903
+ function jsonSizeBytes(value) {
904
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
905
+ }
792
906
  function isNumericKey(value) {
793
907
  return /^\d+$/.test(value.trim());
794
908
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qingflow-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,6 +35,9 @@
35
35
  "start": "node dist/server.js",
36
36
  "prepublishOnly": "npm run build"
37
37
  },
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
38
41
  "dependencies": {
39
42
  "@modelcontextprotocol/sdk": "^1.17.4",
40
43
  "zod": "^3.25.76"