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 +41 -0
- package/dist/qingflow-client.js +15 -5
- package/dist/server.js +123 -9
- package/package.json +4 -1
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
|
package/dist/qingflow-client.js
CHANGED
|
@@ -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 =
|
|
92
|
-
|
|
91
|
+
const headers = {
|
|
92
|
+
accessToken: this.accessToken
|
|
93
|
+
};
|
|
93
94
|
if (options.userId) {
|
|
94
|
-
headers.
|
|
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
|
|
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
|
|
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
|
|
363
|
-
pageSize
|
|
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:
|
|
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
|
|
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) ??
|
|
383
|
-
page_size: toPositiveInt(result?.pageSize) ??
|
|
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
|
-
},
|
|
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.
|
|
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"
|