qingflow-mcp 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # Qingflow MCP (CRUD)
2
+
3
+ This MCP server wraps Qingflow OpenAPI for:
4
+
5
+ - `qf_apps_list`
6
+ - `qf_form_get`
7
+ - `qf_records_list`
8
+ - `qf_record_get`
9
+ - `qf_record_create`
10
+ - `qf_record_update`
11
+ - `qf_operation_get`
12
+
13
+ It intentionally excludes delete for now.
14
+
15
+ ## Setup
16
+
17
+ 1. Install dependencies:
18
+
19
+ ```bash
20
+ npm install
21
+ ```
22
+
23
+ 2. Set environment variables:
24
+
25
+ ```bash
26
+ export QINGFLOW_BASE_URL="https://api.qingflow.com"
27
+ export QINGFLOW_ACCESS_TOKEN="your_access_token"
28
+ ```
29
+
30
+ Optional:
31
+
32
+ ```bash
33
+ export QINGFLOW_FORM_CACHE_TTL_MS=300000
34
+ ```
35
+
36
+ ## Run
37
+
38
+ Development:
39
+
40
+ ```bash
41
+ npm run dev
42
+ ```
43
+
44
+ Build and run:
45
+
46
+ ```bash
47
+ npm run build
48
+ npm start
49
+ ```
50
+
51
+ ## CLI Install
52
+
53
+ Global install from GitHub:
54
+
55
+ ```bash
56
+ npm i -g git+https://github.com/853046310/qingflow-mcp.git
57
+ ```
58
+
59
+ Or one-click installer:
60
+
61
+ ```bash
62
+ curl -fsSL https://raw.githubusercontent.com/853046310/qingflow-mcp/main/install.sh | bash
63
+ ```
64
+
65
+ Safer (review script before execution):
66
+
67
+ ```bash
68
+ curl -fsSL https://raw.githubusercontent.com/853046310/qingflow-mcp/main/install.sh -o install.sh
69
+ less install.sh
70
+ bash install.sh
71
+ ```
72
+
73
+ MCP client config example:
74
+
75
+ ```json
76
+ {
77
+ "mcpServers": {
78
+ "qingflow": {
79
+ "command": "qingflow-mcp",
80
+ "env": {
81
+ "QINGFLOW_BASE_URL": "https://api.qingflow.com",
82
+ "QINGFLOW_ACCESS_TOKEN": "your_access_token"
83
+ }
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ ## Recommended Flow
90
+
91
+ 1. `qf_apps_list` to pick app.
92
+ 2. `qf_form_get` to inspect field ids/titles.
93
+ 3. `qf_record_create` or `qf_record_update`.
94
+ 4. If create/update returns only `request_id`, call `qf_operation_get` to resolve async result.
95
+
96
+ ## Publish
97
+
98
+ ```bash
99
+ npm login
100
+ npm publish
101
+ ```
102
+
103
+ If you publish under an npm scope, use:
104
+
105
+ ```bash
106
+ npm publish --access public
107
+ ```
108
+
109
+ ## Security Notes
110
+
111
+ 1. Keep `QINGFLOW_ACCESS_TOKEN` only in runtime env vars; do not commit `.env`.
112
+ 2. Rotate token immediately if it appears in screenshots, logs, or chat history.
113
+
114
+ ## Community
115
+
116
+ - Contributing: [CONTRIBUTING.md](./CONTRIBUTING.md)
117
+ - Security: [SECURITY.md](./SECURITY.md)
118
+ - Conduct: [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md)
@@ -0,0 +1,255 @@
1
+ export class QingflowApiError extends Error {
2
+ errCode;
3
+ errMsg;
4
+ httpStatus;
5
+ details;
6
+ constructor(params) {
7
+ super(params.message);
8
+ this.name = "QingflowApiError";
9
+ this.errCode = params.errCode ?? null;
10
+ this.errMsg = params.errMsg ?? params.message;
11
+ this.httpStatus = params.httpStatus ?? null;
12
+ this.details = params.details;
13
+ }
14
+ }
15
+ export class QingflowClient {
16
+ baseUrl;
17
+ accessToken;
18
+ timeoutMs;
19
+ constructor(config) {
20
+ this.baseUrl = normalizeBaseUrl(config.baseUrl);
21
+ this.accessToken = normalizeAccessToken(config.accessToken);
22
+ this.timeoutMs = config.timeoutMs ?? 30_000;
23
+ }
24
+ listApps(options = {}) {
25
+ return this.request({
26
+ method: "GET",
27
+ path: "/app",
28
+ options: {
29
+ query: {
30
+ userId: options.userId,
31
+ favourite: options.favourite
32
+ }
33
+ }
34
+ });
35
+ }
36
+ getForm(appKey, options = {}) {
37
+ return this.request({
38
+ method: "GET",
39
+ path: `/app/${encodeURIComponent(appKey)}/form`,
40
+ options: {
41
+ userId: options.userId
42
+ }
43
+ });
44
+ }
45
+ listRecords(appKey, payload, options = {}) {
46
+ return this.request({
47
+ method: "POST",
48
+ path: `/app/${encodeURIComponent(appKey)}/apply/filter`,
49
+ options: {
50
+ userId: options.userId,
51
+ body: payload
52
+ }
53
+ });
54
+ }
55
+ getRecord(applyId) {
56
+ return this.request({
57
+ method: "GET",
58
+ path: `/apply/${encodeURIComponent(applyId)}`
59
+ });
60
+ }
61
+ createRecord(appKey, payload, options = {}) {
62
+ return this.request({
63
+ method: "POST",
64
+ path: `/app/${encodeURIComponent(appKey)}/apply`,
65
+ options: {
66
+ userId: options.userId,
67
+ body: payload
68
+ }
69
+ });
70
+ }
71
+ updateRecord(applyId, payload, options = {}) {
72
+ return this.request({
73
+ method: "POST",
74
+ path: `/apply/${encodeURIComponent(applyId)}`,
75
+ options: {
76
+ userId: options.userId,
77
+ body: payload
78
+ }
79
+ });
80
+ }
81
+ getOperation(requestId) {
82
+ return this.request({
83
+ method: "GET",
84
+ path: `/operation/${encodeURIComponent(requestId)}`
85
+ });
86
+ }
87
+ async request(params) {
88
+ const options = params.options ?? {};
89
+ const url = new URL(params.path, this.baseUrl);
90
+ appendQuery(url, options.query);
91
+ const headers = new Headers();
92
+ headers.set("accessToken", this.accessToken);
93
+ if (options.userId) {
94
+ headers.set("userId", options.userId);
95
+ }
96
+ const init = {
97
+ method: params.method,
98
+ headers
99
+ };
100
+ if (options.body !== undefined) {
101
+ headers.set("content-type", "application/json");
102
+ init.body = JSON.stringify(options.body);
103
+ }
104
+ const controller = new AbortController();
105
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
106
+ init.signal = controller.signal;
107
+ try {
108
+ const response = await fetch(url, init);
109
+ const text = await response.text();
110
+ const data = safeJsonParse(text);
111
+ if (!response.ok) {
112
+ throw new QingflowApiError({
113
+ message: `Qingflow HTTP ${response.status}`,
114
+ httpStatus: response.status,
115
+ errMsg: extractErrMsg(data, text),
116
+ details: data ?? text
117
+ });
118
+ }
119
+ if (!data || typeof data !== "object") {
120
+ throw new QingflowApiError({
121
+ message: "Qingflow response is not JSON object",
122
+ httpStatus: response.status,
123
+ details: text
124
+ });
125
+ }
126
+ const parsed = parseResponseEnvelope(data);
127
+ if (parsed.errCode === null) {
128
+ throw new QingflowApiError({
129
+ message: "Qingflow response missing code field",
130
+ httpStatus: response.status,
131
+ details: data
132
+ });
133
+ }
134
+ if (parsed.errCode !== 0) {
135
+ throw new QingflowApiError({
136
+ message: `Qingflow API error ${parsed.errCode}: ${parsed.errMsg}`,
137
+ errCode: parsed.errCode,
138
+ errMsg: parsed.errMsg,
139
+ httpStatus: response.status,
140
+ details: data
141
+ });
142
+ }
143
+ return {
144
+ errCode: parsed.errCode,
145
+ errMsg: parsed.errMsg,
146
+ result: parsed.result
147
+ };
148
+ }
149
+ catch (error) {
150
+ if (error instanceof QingflowApiError) {
151
+ throw error;
152
+ }
153
+ if (error instanceof Error && error.name === "AbortError") {
154
+ throw new QingflowApiError({
155
+ message: `Qingflow request timeout after ${this.timeoutMs}ms`
156
+ });
157
+ }
158
+ throw new QingflowApiError({
159
+ message: error instanceof Error ? error.message : "Unknown request error",
160
+ details: error
161
+ });
162
+ }
163
+ finally {
164
+ clearTimeout(timer);
165
+ }
166
+ }
167
+ }
168
+ function normalizeBaseUrl(url) {
169
+ const normalized = url.trim();
170
+ if (!normalized) {
171
+ throw new Error("QINGFLOW_BASE_URL is required");
172
+ }
173
+ return normalized.endsWith("/") ? normalized : `${normalized}/`;
174
+ }
175
+ function normalizeAccessToken(token) {
176
+ const normalized = token.trim();
177
+ if (!normalized) {
178
+ throw new Error("QINGFLOW_ACCESS_TOKEN is required");
179
+ }
180
+ if (/[\r\n]/.test(normalized)) {
181
+ throw new Error("QINGFLOW_ACCESS_TOKEN contains newline characters");
182
+ }
183
+ if (/[\u0100-\uFFFF]/.test(normalized)) {
184
+ throw new Error("QINGFLOW_ACCESS_TOKEN contains non-ASCII characters; please paste raw token only");
185
+ }
186
+ return normalized;
187
+ }
188
+ function appendQuery(url, query) {
189
+ if (!query) {
190
+ return;
191
+ }
192
+ for (const [key, value] of Object.entries(query)) {
193
+ if (value === undefined || value === null) {
194
+ continue;
195
+ }
196
+ url.searchParams.set(key, String(value));
197
+ }
198
+ }
199
+ function safeJsonParse(text) {
200
+ try {
201
+ return JSON.parse(text);
202
+ }
203
+ catch {
204
+ return null;
205
+ }
206
+ }
207
+ function extractErrMsg(json, rawText) {
208
+ if (json && typeof json === "object") {
209
+ const obj = json;
210
+ const msgCandidates = [obj.errMsg, obj.errorMsg, obj.message];
211
+ for (const msg of msgCandidates) {
212
+ if (typeof msg === "string" && msg.trim()) {
213
+ return msg;
214
+ }
215
+ }
216
+ }
217
+ const sample = rawText.trim().slice(0, 200);
218
+ return sample || "request failed";
219
+ }
220
+ function parseResponseEnvelope(data) {
221
+ const codeCandidates = [data.errCode, data.errorCode, data.statusCode];
222
+ let errCode = null;
223
+ for (const candidate of codeCandidates) {
224
+ const parsed = toFiniteNumber(candidate);
225
+ if (parsed !== null) {
226
+ errCode = parsed;
227
+ break;
228
+ }
229
+ }
230
+ const messageCandidates = [data.errMsg, data.errorMsg, data.message];
231
+ let errMsg = "";
232
+ for (const candidate of messageCandidates) {
233
+ if (typeof candidate === "string" && candidate.trim()) {
234
+ errMsg = candidate.trim();
235
+ break;
236
+ }
237
+ }
238
+ return {
239
+ errCode,
240
+ errMsg,
241
+ result: data.result
242
+ };
243
+ }
244
+ function toFiniteNumber(value) {
245
+ if (typeof value === "number" && Number.isFinite(value)) {
246
+ return value;
247
+ }
248
+ if (typeof value === "string" && value.trim()) {
249
+ const parsed = Number(value);
250
+ if (Number.isFinite(parsed)) {
251
+ return parsed;
252
+ }
253
+ }
254
+ return null;
255
+ }
package/dist/server.js ADDED
@@ -0,0 +1,889 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { QingflowApiError, QingflowClient } from "./qingflow-client.js";
6
+ const MODE_TO_TYPE = {
7
+ todo: 1,
8
+ done: 2,
9
+ mine_approved: 3,
10
+ mine_rejected: 4,
11
+ mine_draft: 5,
12
+ mine_need_improve: 6,
13
+ mine_processing: 7,
14
+ all: 8,
15
+ all_approved: 9,
16
+ all_rejected: 10,
17
+ all_processing: 11,
18
+ cc: 12
19
+ };
20
+ const FORM_CACHE_TTL_MS = Number(process.env.QINGFLOW_FORM_CACHE_TTL_MS ?? "300000");
21
+ const formCache = new Map();
22
+ const accessToken = process.env.QINGFLOW_ACCESS_TOKEN;
23
+ const baseUrl = process.env.QINGFLOW_BASE_URL;
24
+ if (!accessToken) {
25
+ throw new Error("QINGFLOW_ACCESS_TOKEN is required");
26
+ }
27
+ if (!baseUrl) {
28
+ throw new Error("QINGFLOW_BASE_URL is required");
29
+ }
30
+ const client = new QingflowClient({
31
+ accessToken,
32
+ baseUrl
33
+ });
34
+ const server = new McpServer({
35
+ name: "qingflow-mcp",
36
+ version: "0.2.0"
37
+ });
38
+ const jsonPrimitiveSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
39
+ const answerValueSchema = z.union([
40
+ jsonPrimitiveSchema,
41
+ z
42
+ .object({
43
+ value: z.unknown().optional(),
44
+ dataValue: z.unknown().optional(),
45
+ id: z.union([z.string(), z.number()]).optional(),
46
+ email: z.string().optional(),
47
+ optionId: z.union([z.string(), z.number()]).optional(),
48
+ otherInfo: z.string().optional(),
49
+ queId: z.union([z.string(), z.number()]).optional(),
50
+ valueStr: z.string().optional(),
51
+ matchValue: z.unknown().optional(),
52
+ ordinal: z.union([z.number(), z.string()]).optional(),
53
+ pluginValue: z.unknown().optional()
54
+ })
55
+ .passthrough()
56
+ ]);
57
+ const answerInputSchema = z
58
+ .object({
59
+ que_id: z.union([z.string().min(1), z.number().int()]).optional(),
60
+ queId: z.union([z.string().min(1), z.number().int()]).optional(),
61
+ que_title: z.string().optional(),
62
+ queTitle: z.string().optional(),
63
+ que_type: z.unknown().optional(),
64
+ queType: z.unknown().optional(),
65
+ value: answerValueSchema.optional(),
66
+ values: z.array(answerValueSchema).optional(),
67
+ table_values: z.array(z.array(z.unknown())).optional(),
68
+ tableValues: z.array(z.array(z.unknown())).optional()
69
+ })
70
+ .passthrough()
71
+ .refine((value) => Boolean(value.que_id ?? value.queId), {
72
+ message: "answer item requires que_id or queId"
73
+ })
74
+ .refine((value) => value.value !== undefined ||
75
+ value.values !== undefined ||
76
+ value.table_values !== undefined ||
77
+ value.tableValues !== undefined, {
78
+ message: "answer item requires value(s) or table_values"
79
+ });
80
+ const fieldValueSchema = z.union([
81
+ jsonPrimitiveSchema,
82
+ z.array(z.unknown()),
83
+ z.record(z.unknown())
84
+ ]);
85
+ const apiMetaSchema = z.object({
86
+ provider_err_code: z.number(),
87
+ provider_err_msg: z.string().nullable(),
88
+ base_url: z.string()
89
+ });
90
+ const appSchema = z.object({
91
+ appKey: z.string(),
92
+ appName: z.string()
93
+ });
94
+ const fieldSummarySchema = z.object({
95
+ que_id: z.union([z.number(), z.string(), z.null()]),
96
+ que_title: z.string().nullable(),
97
+ que_type: z.unknown(),
98
+ has_sub_fields: z.boolean(),
99
+ sub_field_count: z.number().int().nonnegative()
100
+ });
101
+ const recordItemSchema = z.object({
102
+ apply_id: z.union([z.string(), z.number(), z.null()]),
103
+ app_key: z.string().nullable(),
104
+ apply_num: z.union([z.number(), z.string(), z.null()]),
105
+ apply_time: z.string().nullable(),
106
+ last_update_time: z.string().nullable(),
107
+ answers: z.array(z.unknown()).optional()
108
+ });
109
+ const operationResultSchema = z.object({
110
+ request_id: z.string(),
111
+ operation_result: z.unknown()
112
+ });
113
+ const appsInputSchema = z.object({
114
+ user_id: z.string().min(1).optional(),
115
+ favourite: z.union([z.literal(0), z.literal(1)]).optional(),
116
+ keyword: z.string().min(1).optional(),
117
+ limit: z.number().int().positive().max(500).optional(),
118
+ offset: z.number().int().nonnegative().optional()
119
+ });
120
+ const appsOutputSchema = z.object({
121
+ ok: z.literal(true),
122
+ data: z.object({
123
+ total_apps: z.number().int().nonnegative(),
124
+ returned_apps: z.number().int().nonnegative(),
125
+ limit: z.number().int().positive(),
126
+ offset: z.number().int().nonnegative(),
127
+ apps: z.array(appSchema)
128
+ }),
129
+ meta: apiMetaSchema
130
+ });
131
+ const formInputSchema = z.object({
132
+ app_key: z.string().min(1),
133
+ user_id: z.string().min(1).optional(),
134
+ force_refresh: z.boolean().optional(),
135
+ include_raw: z.boolean().optional()
136
+ });
137
+ const formOutputSchema = z.object({
138
+ ok: z.literal(true),
139
+ data: z.object({
140
+ app_key: z.string(),
141
+ total_fields: z.number().int().nonnegative(),
142
+ field_summaries: z.array(fieldSummarySchema),
143
+ form: z.unknown().optional()
144
+ }),
145
+ meta: apiMetaSchema
146
+ });
147
+ const listInputSchema = z.object({
148
+ app_key: z.string().min(1),
149
+ user_id: z.string().min(1).optional(),
150
+ page_num: z.number().int().positive().optional(),
151
+ page_size: z.number().int().positive().max(200).optional(),
152
+ mode: z
153
+ .enum([
154
+ "todo",
155
+ "done",
156
+ "mine_approved",
157
+ "mine_rejected",
158
+ "mine_draft",
159
+ "mine_need_improve",
160
+ "mine_processing",
161
+ "all",
162
+ "all_approved",
163
+ "all_rejected",
164
+ "all_processing",
165
+ "cc"
166
+ ])
167
+ .optional(),
168
+ type: z.number().int().min(1).max(12).optional(),
169
+ keyword: z.string().optional(),
170
+ query_logic: z.enum(["and", "or"]).optional(),
171
+ apply_ids: z.array(z.union([z.string(), z.number()])).optional(),
172
+ sort: z
173
+ .array(z.object({
174
+ que_id: z.union([z.string().min(1), z.number().int()]),
175
+ ascend: z.boolean().optional()
176
+ }))
177
+ .optional(),
178
+ filters: z
179
+ .array(z.object({
180
+ que_id: z.union([z.string().min(1), z.number().int()]).optional(),
181
+ search_key: z.string().optional(),
182
+ search_keys: z.array(z.string()).optional(),
183
+ min_value: z.string().optional(),
184
+ max_value: z.string().optional(),
185
+ scope: z.number().int().optional(),
186
+ search_options: z.array(z.union([z.string(), z.number()])).optional(),
187
+ search_user_ids: z.array(z.string()).optional()
188
+ }))
189
+ .optional(),
190
+ include_answers: z.boolean().optional()
191
+ });
192
+ const listOutputSchema = z.object({
193
+ ok: z.literal(true),
194
+ data: z.object({
195
+ app_key: z.string(),
196
+ pagination: z.object({
197
+ page_num: z.number().int().positive(),
198
+ page_size: z.number().int().positive(),
199
+ page_amount: z.number().int().nonnegative().nullable(),
200
+ result_amount: z.number().int().nonnegative()
201
+ }),
202
+ items: z.array(recordItemSchema)
203
+ }),
204
+ meta: apiMetaSchema
205
+ });
206
+ const recordGetInputSchema = z.object({
207
+ apply_id: z.union([z.string().min(1), z.number().int()])
208
+ });
209
+ const recordGetOutputSchema = z.object({
210
+ ok: z.literal(true),
211
+ data: z.object({
212
+ apply_id: z.union([z.string(), z.number(), z.null()]),
213
+ answer_count: z.number().int().nonnegative(),
214
+ record: z.unknown()
215
+ }),
216
+ meta: apiMetaSchema
217
+ });
218
+ const createInputSchema = z
219
+ .object({
220
+ app_key: z.string().min(1),
221
+ user_id: z.string().min(1).optional(),
222
+ force_refresh_form: z.boolean().optional(),
223
+ apply_user: z
224
+ .object({
225
+ email: z.string().optional(),
226
+ areaCode: z.string().optional(),
227
+ mobile: z.string().optional()
228
+ })
229
+ .passthrough()
230
+ .optional(),
231
+ answers: z.array(answerInputSchema).optional(),
232
+ fields: z.record(fieldValueSchema).optional()
233
+ })
234
+ .refine((value) => hasWritePayload(value.answers, value.fields), {
235
+ message: "Either answers or fields is required"
236
+ });
237
+ const createOutputSchema = z.object({
238
+ ok: z.literal(true),
239
+ data: z.object({
240
+ request_id: z.string().nullable(),
241
+ apply_id: z.union([z.string(), z.number(), z.null()]),
242
+ async_hint: z.string()
243
+ }),
244
+ meta: apiMetaSchema
245
+ });
246
+ const updateInputSchema = z
247
+ .object({
248
+ apply_id: z.union([z.string().min(1), z.number().int()]),
249
+ app_key: z.string().min(1).optional(),
250
+ user_id: z.string().min(1).optional(),
251
+ force_refresh_form: z.boolean().optional(),
252
+ answers: z.array(answerInputSchema).optional(),
253
+ fields: z.record(fieldValueSchema).optional()
254
+ })
255
+ .refine((value) => hasWritePayload(value.answers, value.fields), {
256
+ message: "Either answers or fields is required"
257
+ });
258
+ const updateOutputSchema = z.object({
259
+ ok: z.literal(true),
260
+ data: z.object({
261
+ request_id: z.string().nullable(),
262
+ async_hint: z.string()
263
+ }),
264
+ meta: apiMetaSchema
265
+ });
266
+ const operationInputSchema = z.object({
267
+ request_id: z.string().min(1)
268
+ });
269
+ const operationOutputSchema = z.object({
270
+ ok: z.literal(true),
271
+ data: operationResultSchema,
272
+ meta: apiMetaSchema
273
+ });
274
+ server.registerTool("qf_apps_list", {
275
+ title: "Qingflow Apps List",
276
+ description: "List Qingflow apps with optional filtering and client-side slicing.",
277
+ inputSchema: appsInputSchema,
278
+ outputSchema: appsOutputSchema,
279
+ annotations: {
280
+ readOnlyHint: true,
281
+ idempotentHint: true
282
+ }
283
+ }, async (args) => {
284
+ try {
285
+ const response = await client.listApps({
286
+ userId: args.user_id,
287
+ favourite: args.favourite
288
+ });
289
+ const appList = asArray(asObject(response.result)?.appList)
290
+ .map((item) => asObject(item))
291
+ .filter((item) => Boolean(item))
292
+ .map((item) => ({
293
+ appKey: String(item.appKey ?? ""),
294
+ appName: String(item.appName ?? "")
295
+ }))
296
+ .filter((item) => item.appKey.length > 0);
297
+ const keyword = args.keyword?.trim().toLowerCase();
298
+ const filtered = keyword
299
+ ? appList.filter((item) => item.appKey.toLowerCase().includes(keyword) ||
300
+ item.appName.toLowerCase().includes(keyword))
301
+ : appList;
302
+ const offset = args.offset ?? 0;
303
+ const limit = args.limit ?? 50;
304
+ const apps = filtered.slice(offset, offset + limit);
305
+ return okResult({
306
+ ok: true,
307
+ data: {
308
+ total_apps: filtered.length,
309
+ returned_apps: apps.length,
310
+ limit,
311
+ offset,
312
+ apps
313
+ },
314
+ meta: buildMeta(response)
315
+ }, `Returned ${apps.length}/${filtered.length} apps`);
316
+ }
317
+ catch (error) {
318
+ return errorResult(error);
319
+ }
320
+ });
321
+ server.registerTool("qf_form_get", {
322
+ title: "Qingflow Form Get",
323
+ description: "Get form metadata and compact field summaries for one app.",
324
+ inputSchema: formInputSchema,
325
+ outputSchema: formOutputSchema,
326
+ annotations: {
327
+ readOnlyHint: true,
328
+ idempotentHint: true
329
+ }
330
+ }, async (args) => {
331
+ try {
332
+ const response = await getFormCached(args.app_key, args.user_id, Boolean(args.force_refresh));
333
+ const form = asObject(response.result);
334
+ const fieldSummaries = extractFieldSummaries(form);
335
+ return okResult({
336
+ ok: true,
337
+ data: {
338
+ app_key: args.app_key,
339
+ total_fields: fieldSummaries.length,
340
+ field_summaries: fieldSummaries,
341
+ ...(args.include_raw ? { form: response.result } : {})
342
+ },
343
+ meta: buildMeta(response)
344
+ }, `Fetched form for ${args.app_key}`);
345
+ }
346
+ catch (error) {
347
+ return errorResult(error);
348
+ }
349
+ });
350
+ server.registerTool("qf_records_list", {
351
+ title: "Qingflow Records List",
352
+ description: "List records with pagination, filters and sorting.",
353
+ inputSchema: listInputSchema,
354
+ outputSchema: listOutputSchema,
355
+ annotations: {
356
+ readOnlyHint: true,
357
+ idempotentHint: true
358
+ }
359
+ }, async (args) => {
360
+ try {
361
+ const payload = buildListPayload({
362
+ pageNum: args.page_num ?? 1,
363
+ pageSize: args.page_size ?? 50,
364
+ mode: args.mode,
365
+ type: args.type,
366
+ keyword: args.keyword,
367
+ queryLogic: args.query_logic,
368
+ applyIds: args.apply_ids,
369
+ sort: args.sort,
370
+ filters: args.filters
371
+ });
372
+ const response = await client.listRecords(args.app_key, payload, { userId: args.user_id });
373
+ const result = asObject(response.result);
374
+ const rawItems = asArray(result?.result);
375
+ const includeAnswers = Boolean(args.include_answers);
376
+ const items = rawItems.map((raw) => normalizeRecordItem(raw, includeAnswers));
377
+ return okResult({
378
+ ok: true,
379
+ data: {
380
+ app_key: args.app_key,
381
+ pagination: {
382
+ page_num: toPositiveInt(result?.pageNum) ?? (args.page_num ?? 1),
383
+ page_size: toPositiveInt(result?.pageSize) ?? (args.page_size ?? 50),
384
+ page_amount: toNonNegativeInt(result?.pageAmount),
385
+ result_amount: toNonNegativeInt(result?.resultAmount) ?? items.length
386
+ },
387
+ items
388
+ },
389
+ meta: buildMeta(response)
390
+ }, `Fetched ${items.length} records`);
391
+ }
392
+ catch (error) {
393
+ return errorResult(error);
394
+ }
395
+ });
396
+ server.registerTool("qf_record_get", {
397
+ title: "Qingflow Record Get",
398
+ description: "Get one record by applyId.",
399
+ inputSchema: recordGetInputSchema,
400
+ outputSchema: recordGetOutputSchema,
401
+ annotations: {
402
+ readOnlyHint: true,
403
+ idempotentHint: true
404
+ }
405
+ }, async (args) => {
406
+ try {
407
+ const response = await client.getRecord(String(args.apply_id));
408
+ const record = asObject(response.result) ?? {};
409
+ const answerCount = asArray(record.answers).length;
410
+ return okResult({
411
+ ok: true,
412
+ data: {
413
+ apply_id: record.applyId ?? null,
414
+ answer_count: answerCount,
415
+ record: response.result
416
+ },
417
+ meta: buildMeta(response)
418
+ }, `Fetched record ${String(args.apply_id)}`);
419
+ }
420
+ catch (error) {
421
+ return errorResult(error);
422
+ }
423
+ });
424
+ server.registerTool("qf_record_create", {
425
+ title: "Qingflow Record Create",
426
+ description: "Create one record. Supports explicit answers and ergonomic fields mapping (title or queId).",
427
+ inputSchema: createInputSchema,
428
+ outputSchema: createOutputSchema,
429
+ annotations: {
430
+ readOnlyHint: false,
431
+ idempotentHint: false
432
+ }
433
+ }, async (args) => {
434
+ try {
435
+ const form = needsFormResolution(args.fields) || Boolean(args.force_refresh_form)
436
+ ? await getFormCached(args.app_key, args.user_id, Boolean(args.force_refresh_form))
437
+ : null;
438
+ const normalizedAnswers = resolveAnswers({
439
+ explicitAnswers: args.answers,
440
+ fields: args.fields,
441
+ form: form?.result
442
+ });
443
+ const payload = {
444
+ answers: normalizedAnswers
445
+ };
446
+ if (args.apply_user) {
447
+ payload.applyUser = args.apply_user;
448
+ }
449
+ const response = await client.createRecord(args.app_key, payload, {
450
+ userId: args.user_id
451
+ });
452
+ const result = asObject(response.result);
453
+ return okResult({
454
+ ok: true,
455
+ data: {
456
+ request_id: asNullableString(result?.requestId),
457
+ apply_id: result?.applyId ?? null,
458
+ async_hint: "Use qf_operation_get with request_id when apply_id is null."
459
+ },
460
+ meta: buildMeta(response)
461
+ }, `Create request sent for app ${args.app_key}`);
462
+ }
463
+ catch (error) {
464
+ return errorResult(error);
465
+ }
466
+ });
467
+ server.registerTool("qf_record_update", {
468
+ title: "Qingflow Record Update",
469
+ description: "Patch one record by applyId with explicit answers or ergonomic fields mapping.",
470
+ inputSchema: updateInputSchema,
471
+ outputSchema: updateOutputSchema,
472
+ annotations: {
473
+ readOnlyHint: false,
474
+ idempotentHint: false
475
+ }
476
+ }, async (args) => {
477
+ try {
478
+ const requiresForm = needsFormResolution(args.fields);
479
+ if (requiresForm && !args.app_key) {
480
+ throw new Error("app_key is required when fields uses title-based keys");
481
+ }
482
+ const form = requiresForm && args.app_key
483
+ ? await getFormCached(args.app_key, args.user_id, Boolean(args.force_refresh_form))
484
+ : null;
485
+ const normalizedAnswers = resolveAnswers({
486
+ explicitAnswers: args.answers,
487
+ fields: args.fields,
488
+ form: form?.result
489
+ });
490
+ const response = await client.updateRecord(String(args.apply_id), { answers: normalizedAnswers }, { userId: args.user_id });
491
+ const result = asObject(response.result);
492
+ return okResult({
493
+ ok: true,
494
+ data: {
495
+ request_id: asNullableString(result?.requestId),
496
+ async_hint: "Use qf_operation_get with request_id to fetch update result when needed."
497
+ },
498
+ meta: buildMeta(response)
499
+ }, `Update request sent for apply ${String(args.apply_id)}`);
500
+ }
501
+ catch (error) {
502
+ return errorResult(error);
503
+ }
504
+ });
505
+ server.registerTool("qf_operation_get", {
506
+ title: "Qingflow Operation Get",
507
+ description: "Resolve async operation result by request_id.",
508
+ inputSchema: operationInputSchema,
509
+ outputSchema: operationOutputSchema,
510
+ annotations: {
511
+ readOnlyHint: true,
512
+ idempotentHint: true
513
+ }
514
+ }, async (args) => {
515
+ try {
516
+ const response = await client.getOperation(args.request_id);
517
+ return okResult({
518
+ ok: true,
519
+ data: {
520
+ request_id: args.request_id,
521
+ operation_result: response.result
522
+ },
523
+ meta: buildMeta(response)
524
+ }, `Resolved operation ${args.request_id}`);
525
+ }
526
+ catch (error) {
527
+ return errorResult(error);
528
+ }
529
+ });
530
+ async function main() {
531
+ const transport = new StdioServerTransport();
532
+ await server.connect(transport);
533
+ }
534
+ void main();
535
+ function hasWritePayload(answers, fields) {
536
+ return Boolean((answers && answers.length > 0) || (fields && Object.keys(fields).length > 0));
537
+ }
538
+ function buildMeta(response) {
539
+ return {
540
+ provider_err_code: response.errCode,
541
+ provider_err_msg: response.errMsg || null,
542
+ base_url: baseUrl
543
+ };
544
+ }
545
+ function buildListPayload(params) {
546
+ const payload = {
547
+ pageNum: params.pageNum,
548
+ pageSize: params.pageSize
549
+ };
550
+ if (params.mode) {
551
+ payload.type = MODE_TO_TYPE[params.mode];
552
+ }
553
+ else if (params.type !== undefined) {
554
+ payload.type = params.type;
555
+ }
556
+ if (params.keyword) {
557
+ payload.queryKey = params.keyword;
558
+ }
559
+ if (params.queryLogic) {
560
+ payload.queriesRel = params.queryLogic;
561
+ }
562
+ if (params.applyIds?.length) {
563
+ payload.applyIds = params.applyIds.map((id) => String(id));
564
+ }
565
+ if (params.sort?.length) {
566
+ payload.sorts = params.sort.map((item) => ({
567
+ queId: item.que_id,
568
+ ...(item.ascend !== undefined ? { isAscend: item.ascend } : {})
569
+ }));
570
+ }
571
+ if (params.filters?.length) {
572
+ payload.queries = params.filters.map((item) => ({
573
+ ...(item.que_id !== undefined ? { queId: item.que_id } : {}),
574
+ ...(item.search_key !== undefined ? { searchKey: item.search_key } : {}),
575
+ ...(item.search_keys !== undefined ? { searchKeys: item.search_keys } : {}),
576
+ ...(item.min_value !== undefined ? { minValue: item.min_value } : {}),
577
+ ...(item.max_value !== undefined ? { maxValue: item.max_value } : {}),
578
+ ...(item.scope !== undefined ? { scope: item.scope } : {}),
579
+ ...(item.search_options !== undefined ? { searchOptions: item.search_options } : {}),
580
+ ...(item.search_user_ids !== undefined ? { searchUserIds: item.search_user_ids } : {})
581
+ }));
582
+ }
583
+ return payload;
584
+ }
585
+ function normalizeRecordItem(raw, includeAnswers) {
586
+ const item = asObject(raw) ?? {};
587
+ const normalized = {
588
+ apply_id: item.applyId ?? null,
589
+ app_key: asNullableString(item.appKey),
590
+ apply_num: item.applyNum ?? null,
591
+ apply_time: asNullableString(item.applyTime),
592
+ last_update_time: asNullableString(item.lastUpdateTime),
593
+ ...(includeAnswers ? { answers: asArray(item.answers) } : {})
594
+ };
595
+ return normalized;
596
+ }
597
+ function resolveAnswers(params) {
598
+ const normalizedFromFields = resolveFieldAnswers(params.fields, params.form);
599
+ const normalizedExplicit = normalizeExplicitAnswers(params.explicitAnswers);
600
+ const merged = new Map();
601
+ for (const answer of normalizedFromFields) {
602
+ merged.set(String(answer.queId), answer);
603
+ }
604
+ for (const answer of normalizedExplicit) {
605
+ merged.set(String(answer.queId), answer);
606
+ }
607
+ if (merged.size === 0) {
608
+ throw new Error("answers or fields must contain at least one field");
609
+ }
610
+ return Array.from(merged.values());
611
+ }
612
+ function normalizeExplicitAnswers(answers) {
613
+ if (!answers?.length) {
614
+ return [];
615
+ }
616
+ const output = [];
617
+ for (const item of answers) {
618
+ const queId = item.que_id ?? item.queId;
619
+ if (queId === undefined || queId === null || String(queId).trim() === "") {
620
+ throw new Error("answer item requires que_id or queId");
621
+ }
622
+ const normalized = {
623
+ queId: isNumericKey(String(queId)) ? Number(queId) : String(queId)
624
+ };
625
+ const queTitle = item.que_title ?? item.queTitle;
626
+ if (typeof queTitle === "string" && queTitle.trim()) {
627
+ normalized.queTitle = queTitle;
628
+ }
629
+ const queType = item.que_type ?? item.queType;
630
+ if (queType !== undefined) {
631
+ normalized.queType = queType;
632
+ }
633
+ const tableValues = item.table_values ?? item.tableValues;
634
+ if (tableValues !== undefined) {
635
+ normalized.tableValues = tableValues;
636
+ output.push(normalized);
637
+ continue;
638
+ }
639
+ const values = item.values ?? (item.value !== undefined ? [item.value] : undefined);
640
+ if (values === undefined) {
641
+ throw new Error(`answer item ${String(queId)} requires values or table_values`);
642
+ }
643
+ normalized.values = values.map((value) => normalizeAnswerValue(value));
644
+ output.push(normalized);
645
+ }
646
+ return output;
647
+ }
648
+ function resolveFieldAnswers(fields, form) {
649
+ const entries = Object.entries(fields ?? {});
650
+ if (entries.length === 0) {
651
+ return [];
652
+ }
653
+ const index = buildFieldIndex(form);
654
+ const answers = [];
655
+ for (const [fieldKey, fieldValue] of entries) {
656
+ const field = resolveFieldByKey(fieldKey, index);
657
+ if (!field) {
658
+ throw new Error(`Cannot resolve field key "${fieldKey}" from form metadata`);
659
+ }
660
+ answers.push(makeAnswerFromField(field, fieldValue));
661
+ }
662
+ return answers;
663
+ }
664
+ function makeAnswerFromField(field, value) {
665
+ const base = {
666
+ queId: field.queId
667
+ };
668
+ if (field.queTitle !== undefined) {
669
+ base.queTitle = field.queTitle;
670
+ }
671
+ if (field.queType !== undefined) {
672
+ base.queType = field.queType;
673
+ }
674
+ if (value && typeof value === "object" && !Array.isArray(value)) {
675
+ const objectValue = value;
676
+ if ("tableValues" in objectValue || "table_values" in objectValue) {
677
+ return {
678
+ ...base,
679
+ tableValues: objectValue.tableValues ?? objectValue.table_values
680
+ };
681
+ }
682
+ if ("values" in objectValue) {
683
+ return {
684
+ ...base,
685
+ values: asArray(objectValue.values).map((item) => normalizeAnswerValue(item))
686
+ };
687
+ }
688
+ }
689
+ if (Array.isArray(value) && value.length > 0 && Array.isArray(value[0])) {
690
+ return {
691
+ ...base,
692
+ tableValues: value
693
+ };
694
+ }
695
+ const valueArray = Array.isArray(value) ? value : [value];
696
+ return {
697
+ ...base,
698
+ values: valueArray.map((item) => normalizeAnswerValue(item))
699
+ };
700
+ }
701
+ function normalizeAnswerValue(value) {
702
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
703
+ return {
704
+ value,
705
+ dataValue: value
706
+ };
707
+ }
708
+ return value;
709
+ }
710
+ function needsFormResolution(fields) {
711
+ const keys = Object.keys(fields ?? {});
712
+ if (!keys.length) {
713
+ return false;
714
+ }
715
+ return keys.some((key) => !isNumericKey(key));
716
+ }
717
+ async function getFormCached(appKey, userId, forceRefresh = false) {
718
+ const cacheKey = `${appKey}::${userId ?? ""}`;
719
+ if (!forceRefresh) {
720
+ const cached = formCache.get(cacheKey);
721
+ if (cached && cached.expiresAt > Date.now()) {
722
+ return cached.data;
723
+ }
724
+ }
725
+ const response = await client.getForm(appKey, { userId });
726
+ formCache.set(cacheKey, {
727
+ expiresAt: Date.now() + FORM_CACHE_TTL_MS,
728
+ data: response
729
+ });
730
+ return response;
731
+ }
732
+ function extractFieldSummaries(form) {
733
+ const root = asArray(form?.questionBaseInfos);
734
+ return root.map((raw) => {
735
+ const field = asObject(raw) ?? {};
736
+ const sub = asArray(field.subQuestionBaseInfos);
737
+ return {
738
+ que_id: field.queId ?? null,
739
+ que_title: asNullableString(field.queTitle),
740
+ que_type: field.queType,
741
+ has_sub_fields: sub.length > 0,
742
+ sub_field_count: sub.length
743
+ };
744
+ });
745
+ }
746
+ function buildFieldIndex(form) {
747
+ const byId = new Map();
748
+ const byTitle = new Map();
749
+ const root = asArray(asObject(form)?.questionBaseInfos);
750
+ const queue = [...root];
751
+ while (queue.length > 0) {
752
+ const current = queue.shift();
753
+ if (!current) {
754
+ continue;
755
+ }
756
+ if (current.queId !== undefined && current.queId !== null) {
757
+ byId.set(String(current.queId), current);
758
+ }
759
+ if (typeof current.queTitle === "string" && current.queTitle.trim()) {
760
+ const titleKey = current.queTitle.trim().toLowerCase();
761
+ const list = byTitle.get(titleKey) ?? [];
762
+ list.push(current);
763
+ byTitle.set(titleKey, list);
764
+ }
765
+ const sub = asArray(current.subQuestionBaseInfos);
766
+ for (const child of sub) {
767
+ queue.push(child);
768
+ }
769
+ }
770
+ return { byId, byTitle };
771
+ }
772
+ function resolveFieldByKey(fieldKey, index) {
773
+ if (isNumericKey(fieldKey)) {
774
+ const normalized = String(Number(fieldKey));
775
+ const hit = index.byId.get(normalized);
776
+ if (hit) {
777
+ return hit;
778
+ }
779
+ return { queId: Number(fieldKey) };
780
+ }
781
+ const titleKey = fieldKey.trim().toLowerCase();
782
+ const matches = index.byTitle.get(titleKey) ?? [];
783
+ if (matches.length === 1) {
784
+ return matches[0];
785
+ }
786
+ if (matches.length > 1) {
787
+ const candidateIds = matches.map((item) => String(item.queId)).join(", ");
788
+ throw new Error(`Field title "${fieldKey}" is ambiguous. Candidate queId: ${candidateIds}`);
789
+ }
790
+ return null;
791
+ }
792
+ function isNumericKey(value) {
793
+ return /^\d+$/.test(value.trim());
794
+ }
795
+ function okResult(payload, message) {
796
+ return {
797
+ structuredContent: payload,
798
+ content: [
799
+ {
800
+ type: "text",
801
+ text: message
802
+ }
803
+ ]
804
+ };
805
+ }
806
+ function errorResult(error) {
807
+ const payload = toErrorPayload(error);
808
+ return {
809
+ isError: true,
810
+ structuredContent: payload,
811
+ content: [
812
+ {
813
+ type: "text",
814
+ text: JSON.stringify(payload, null, 2)
815
+ }
816
+ ]
817
+ };
818
+ }
819
+ function toErrorPayload(error) {
820
+ if (error instanceof QingflowApiError) {
821
+ return {
822
+ ok: false,
823
+ message: error.message,
824
+ err_code: error.errCode,
825
+ err_msg: error.errMsg || null,
826
+ http_status: error.httpStatus,
827
+ details: error.details ?? null
828
+ };
829
+ }
830
+ if (error instanceof z.ZodError) {
831
+ return {
832
+ ok: false,
833
+ message: "Invalid arguments",
834
+ issues: error.issues
835
+ };
836
+ }
837
+ if (error instanceof Error) {
838
+ return {
839
+ ok: false,
840
+ message: error.message
841
+ };
842
+ }
843
+ return {
844
+ ok: false,
845
+ message: "Unknown error",
846
+ details: error
847
+ };
848
+ }
849
+ function asObject(value) {
850
+ return value && typeof value === "object" && !Array.isArray(value)
851
+ ? value
852
+ : null;
853
+ }
854
+ function asArray(value) {
855
+ return Array.isArray(value) ? value : [];
856
+ }
857
+ function toPositiveInt(value) {
858
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) {
859
+ return value;
860
+ }
861
+ if (typeof value === "string" && value.trim()) {
862
+ const parsed = Number(value);
863
+ if (Number.isInteger(parsed) && parsed > 0) {
864
+ return parsed;
865
+ }
866
+ }
867
+ return null;
868
+ }
869
+ function toNonNegativeInt(value) {
870
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0) {
871
+ return value;
872
+ }
873
+ if (typeof value === "string" && value.trim()) {
874
+ const parsed = Number(value);
875
+ if (Number.isInteger(parsed) && parsed >= 0) {
876
+ return parsed;
877
+ }
878
+ }
879
+ return null;
880
+ }
881
+ function asNullableString(value) {
882
+ if (typeof value === "string") {
883
+ return value;
884
+ }
885
+ if (typeof value === "number" || typeof value === "boolean") {
886
+ return String(value);
887
+ }
888
+ return null;
889
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "qingflow-mcp",
3
+ "version": "0.2.0",
4
+ "private": false,
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "description": "MCP server for Qingflow CRUD workflows",
8
+ "author": "Yanqi Dong",
9
+ "homepage": "https://github.com/853046310/qingflow-mcp#readme",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/853046310/qingflow-mcp.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/853046310/qingflow-mcp/issues"
16
+ },
17
+ "keywords": [
18
+ "mcp",
19
+ "qingflow",
20
+ "openapi",
21
+ "automation",
22
+ "agent"
23
+ ],
24
+ "bin": {
25
+ "qingflow-mcp": "dist/server.js"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "scripts": {
33
+ "build": "tsc -p tsconfig.json",
34
+ "dev": "tsx src/server.ts",
35
+ "start": "node dist/server.js",
36
+ "prepublishOnly": "npm run build"
37
+ },
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.17.4",
40
+ "zod": "^3.25.76"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^22.18.1",
44
+ "tsx": "^4.20.5",
45
+ "typescript": "^5.9.2"
46
+ }
47
+ }