@fruggr/zendesk-mcp-server 2.20.0 → 2.20.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/dist/index.js +91 -36
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import "zod/compile";
|
|
2
3
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
4
|
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { createServer } from "node:http";
|
|
@@ -128,6 +129,11 @@ const CHARACTER_LIMIT = positiveIntEnv("ZENDESK_CHARACTER_LIMIT", 25e3);
|
|
|
128
129
|
const ARTICLE_RESOURCES_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES", 20);
|
|
129
130
|
const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5242880);
|
|
130
131
|
const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("ZENDESK_MAX_EMBEDDED_IMAGES", 10);
|
|
132
|
+
const STDIO_MAX_MESSAGE_BYTES = 10485760;
|
|
133
|
+
const MESSAGE_CONTENT_BUDGET_BYTES = 10420224;
|
|
134
|
+
const MAX_RESPONSE_BYTES = Math.min(positiveIntEnv("ZENDESK_MAX_RESPONSE_BYTES", MESSAGE_CONTENT_BUDGET_BYTES), MESSAGE_CONTENT_BUDGET_BYTES);
|
|
135
|
+
const MAX_BASE64_INPUT_CHARS = MESSAGE_CONTENT_BUDGET_BYTES;
|
|
136
|
+
const MAX_BASE64_INPUT_MB = Number.parseFloat((MAX_BASE64_INPUT_CHARS / 4 * 3 / 1048576).toFixed(2));
|
|
131
137
|
const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
|
|
132
138
|
const REORDER_CONFIRM_THRESHOLD = positiveIntEnv("ZENDESK_REORDER_CONFIRM_THRESHOLD", 20);
|
|
133
139
|
const getBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2`;
|
|
@@ -3082,7 +3088,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
3082
3088
|
inputSchema: z.object({
|
|
3083
3089
|
article_id: z.number().int().describe(ARTICLE_ID_DESC),
|
|
3084
3090
|
file_name: z.string().min(1).describe("Name to store the file under, including its extension (e.g. \"screenshot.png\"); used as the download name."),
|
|
3085
|
-
file_base64: z.string().min(1).
|
|
3091
|
+
file_base64: z.string().min(1).max(MAX_BASE64_INPUT_CHARS, {
|
|
3092
|
+
abort: true,
|
|
3093
|
+
error: (issue) => `File too large: ${issue.input.length} base64 characters, limit ${MAX_BASE64_INPUT_CHARS}. Downscale the file, split it, or host it elsewhere and link to it.`
|
|
3094
|
+
}).describe(`The file's raw bytes as a base64-encoded string; the server decodes them before upload. At most ${MAX_BASE64_INPUT_CHARS} characters (about ${MAX_BASE64_INPUT_MB} MB of file); the HTTP transport additionally caps request bodies at 4 MB.`),
|
|
3086
3095
|
content_type: z.string().default("application/octet-stream").describe("MIME type of the file, e.g. \"image/png\" or \"application/pdf\". Defaults to application/octet-stream when omitted.")
|
|
3087
3096
|
}),
|
|
3088
3097
|
annotations: {
|
|
@@ -3162,6 +3171,28 @@ const createSearchTools = (ctx) => {
|
|
|
3162
3171
|
//#endregion
|
|
3163
3172
|
//#region src/tools/tickets.ts
|
|
3164
3173
|
const MAX_ATTACHMENT_MB = Number.parseFloat((MAX_ATTACHMENT_BYTES / 1048576).toFixed(2));
|
|
3174
|
+
const MAX_RESPONSE_MB = Number.parseFloat((MAX_RESPONSE_BYTES / 1048576).toFixed(2));
|
|
3175
|
+
const blockCost = (block) => block.type === "image" ? Buffer.byteLength(JSON.stringify({
|
|
3176
|
+
...block,
|
|
3177
|
+
data: ""
|
|
3178
|
+
}), "utf8") + block.data.length + 1 : Buffer.byteLength(JSON.stringify(block), "utf8") + 1;
|
|
3179
|
+
const embeddedCost = (attachment, reference) => blockCost({
|
|
3180
|
+
type: "image",
|
|
3181
|
+
data: "",
|
|
3182
|
+
mimeType: attachment.content_type
|
|
3183
|
+
}) + 4 * Math.ceil(attachment.size / 3) + blockCost({
|
|
3184
|
+
type: "text",
|
|
3185
|
+
text: reference
|
|
3186
|
+
});
|
|
3187
|
+
const BUDGET_REACHED = `skipped: response budget of ${MAX_RESPONSE_MB} MB reached`;
|
|
3188
|
+
const referenceBlock = (reference, skipReason) => ({
|
|
3189
|
+
type: "text",
|
|
3190
|
+
text: skipReason ? `${reference} — ${skipReason}` : reference
|
|
3191
|
+
});
|
|
3192
|
+
const truncationNotice = (omitted) => ({
|
|
3193
|
+
type: "text",
|
|
3194
|
+
text: `${omitted} further attachments omitted: response budget of ${MAX_RESPONSE_MB} MB reached`
|
|
3195
|
+
});
|
|
3165
3196
|
const formatReference = (attachment) => `**${attachment.file_name}** (id ${attachment.id}, ${attachment.content_type}, ${attachment.size} bytes) — ${attachment.content_url}`;
|
|
3166
3197
|
const buildEmbeddedImageBlocks = async (subdomain, token, attachment, reference) => {
|
|
3167
3198
|
const { data, contentType } = await fetchZendeskBinary(subdomain, token, attachment.content_url);
|
|
@@ -3210,38 +3241,52 @@ const fetchAttachmentsByIds = async (subdomain, token, ids) => {
|
|
|
3210
3241
|
}
|
|
3211
3242
|
return attachments;
|
|
3212
3243
|
};
|
|
3213
|
-
const
|
|
3244
|
+
const imageSkipReason = (attachment, embeddedCount, reference, fits) => {
|
|
3245
|
+
if (attachment.size > MAX_ATTACHMENT_BYTES) return `skipped: exceeds ${MAX_ATTACHMENT_MB} MB per-image limit`;
|
|
3246
|
+
if (embeddedCount >= MAX_EMBEDDED_IMAGE_COUNT) return `skipped: max ${MAX_EMBEDDED_IMAGE_COUNT} embedded images reached`;
|
|
3247
|
+
if (!fits(embeddedCost(attachment, reference))) return BUDGET_REACHED;
|
|
3248
|
+
return null;
|
|
3249
|
+
};
|
|
3250
|
+
const overflowFallback = (produced, reference, fits) => {
|
|
3251
|
+
if (!produced.some((block) => block.type === "image")) return null;
|
|
3252
|
+
const fallback = referenceBlock(reference, BUDGET_REACHED);
|
|
3253
|
+
return fits(blockCost(fallback)) ? fallback : null;
|
|
3254
|
+
};
|
|
3255
|
+
const embedOrExplain = async (subdomain, token, attachment, reference) => {
|
|
3256
|
+
try {
|
|
3257
|
+
return await buildEmbeddedImageBlocks(subdomain, token, attachment, reference);
|
|
3258
|
+
} catch (error) {
|
|
3259
|
+
return [{
|
|
3260
|
+
type: "text",
|
|
3261
|
+
text: `${reference} — ${error instanceof ZendeskApiError ? `download failed: ${error.status} ${error.statusText}` : "download failed"}`
|
|
3262
|
+
}];
|
|
3263
|
+
}
|
|
3264
|
+
};
|
|
3265
|
+
const collectAttachmentBlocks = async (subdomain, token, attachments, consumedBytes = 0) => {
|
|
3214
3266
|
const blocks = [];
|
|
3215
3267
|
let embeddedCount = 0;
|
|
3216
|
-
|
|
3268
|
+
let responseBytes = consumedBytes;
|
|
3269
|
+
const noticeReserve = blockCost(truncationNotice(attachments.length));
|
|
3270
|
+
const fits = (cost) => responseBytes + cost + noticeReserve <= MAX_RESPONSE_BYTES;
|
|
3271
|
+
for (const [index, attachment] of attachments.entries()) {
|
|
3217
3272
|
const reference = formatReference(attachment);
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
blocks.push(
|
|
3230
|
-
|
|
3231
|
-
text: `${reference} — ${skipReason}`
|
|
3232
|
-
});
|
|
3233
|
-
continue;
|
|
3234
|
-
}
|
|
3235
|
-
try {
|
|
3236
|
-
blocks.push(...await buildEmbeddedImageBlocks(subdomain, token, attachment, reference));
|
|
3237
|
-
embeddedCount += 1;
|
|
3238
|
-
} catch (error) {
|
|
3239
|
-
const reason = error instanceof ZendeskApiError ? `download failed: ${error.status} ${error.statusText}` : "download failed";
|
|
3240
|
-
blocks.push({
|
|
3241
|
-
type: "text",
|
|
3242
|
-
text: `${reference} — ${reason}`
|
|
3243
|
-
});
|
|
3273
|
+
const isImage = attachment.content_type.startsWith("image/");
|
|
3274
|
+
const skipReason = isImage ? imageSkipReason(attachment, embeddedCount, reference, fits) : null;
|
|
3275
|
+
const produced = isImage && !skipReason ? await embedOrExplain(subdomain, token, attachment, reference) : [referenceBlock(reference, skipReason)];
|
|
3276
|
+
const cost = produced.reduce((total, block) => total + blockCost(block), 0);
|
|
3277
|
+
if (!fits(cost)) {
|
|
3278
|
+
const fallback = overflowFallback(produced, reference, fits);
|
|
3279
|
+
if (fallback) {
|
|
3280
|
+
blocks.push(fallback);
|
|
3281
|
+
responseBytes += blockCost(fallback);
|
|
3282
|
+
continue;
|
|
3283
|
+
}
|
|
3284
|
+
blocks.push(truncationNotice(attachments.length - index));
|
|
3285
|
+
break;
|
|
3244
3286
|
}
|
|
3287
|
+
blocks.push(...produced);
|
|
3288
|
+
responseBytes += cost;
|
|
3289
|
+
if (produced.some((block) => block.type === "image")) embeddedCount += 1;
|
|
3245
3290
|
}
|
|
3246
3291
|
return blocks;
|
|
3247
3292
|
};
|
|
@@ -3436,9 +3481,19 @@ const createTicketTools = (ctx) => {
|
|
|
3436
3481
|
const { subdomain, getToken } = ctx;
|
|
3437
3482
|
const attachmentSchema = z.object({
|
|
3438
3483
|
file_name: z.string().min(1).describe("File name, e.g. \"app.log\" or \"screenshot.png\"."),
|
|
3439
|
-
file_base64: z.string().min(1).
|
|
3484
|
+
file_base64: z.string().min(1).max(MAX_BASE64_INPUT_CHARS, {
|
|
3485
|
+
abort: true,
|
|
3486
|
+
error: (issue) => `Attachment too large: ${issue.input.length} base64 characters, limit ${MAX_BASE64_INPUT_CHARS}. Downscale the file, split the upload, or link to it instead of uploading.`
|
|
3487
|
+
}).base64().describe(`File content encoded as base64. At most ${MAX_BASE64_INPUT_CHARS} characters (about ${MAX_BASE64_INPUT_MB} MB of file), and the attachments of one call must stay under that total; the HTTP transport additionally caps request bodies at 4 MB.`),
|
|
3440
3488
|
content_type: z.string().min(1).default("application/octet-stream").describe("MIME type, e.g. \"text/plain\", \"image/png\", \"application/pdf\".")
|
|
3441
3489
|
});
|
|
3490
|
+
const attachmentsParam = (description) => z.array(attachmentSchema).superRefine((files, refinement) => {
|
|
3491
|
+
const total = files.reduce((sum, file) => sum + file.file_base64.length, 0);
|
|
3492
|
+
if (total > 10420224) refinement.addIssue({
|
|
3493
|
+
code: "custom",
|
|
3494
|
+
message: `Attachments too large: ${total} base64 characters in total, limit ${MAX_BASE64_INPUT_CHARS}. Send fewer files per call.`
|
|
3495
|
+
});
|
|
3496
|
+
}).optional().describe(description);
|
|
3442
3497
|
const uploadAttachments = async (token, files) => {
|
|
3443
3498
|
let uploadToken;
|
|
3444
3499
|
for (const file of files) {
|
|
@@ -3601,11 +3656,11 @@ const createTicketTools = (ctx) => {
|
|
|
3601
3656
|
type: "text",
|
|
3602
3657
|
text: `No attachments found on ticket #${ticket_id}.`
|
|
3603
3658
|
}] };
|
|
3604
|
-
const
|
|
3605
|
-
return { content: [{
|
|
3659
|
+
const header = {
|
|
3606
3660
|
type: "text",
|
|
3607
3661
|
text: `# Attachments for ticket #${ticket_id} (${attachments.length} total)`
|
|
3608
|
-
}
|
|
3662
|
+
};
|
|
3663
|
+
return { content: [header, ...await collectAttachmentBlocks(subdomain, token, attachments, blockCost(header) + 1)] };
|
|
3609
3664
|
}
|
|
3610
3665
|
},
|
|
3611
3666
|
{
|
|
@@ -3751,7 +3806,7 @@ const createTicketTools = (ctx) => {
|
|
|
3751
3806
|
inputSchema: z.object({
|
|
3752
3807
|
ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket to annotate. Obtain it from search_tickets or list_tickets."),
|
|
3753
3808
|
body: z.string().min(1).describe("Note text (internal, agent-only). Plain text or HTML; not shown to the requester."),
|
|
3754
|
-
attachments:
|
|
3809
|
+
attachments: attachmentsParam("Files to attach to this note (base64-encoded content).")
|
|
3755
3810
|
}),
|
|
3756
3811
|
annotations: {
|
|
3757
3812
|
readOnlyHint: false,
|
|
@@ -3783,7 +3838,7 @@ const createTicketTools = (ctx) => {
|
|
|
3783
3838
|
inputSchema: z.object({
|
|
3784
3839
|
ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket to reply on. Obtain it from search_tickets or list_tickets."),
|
|
3785
3840
|
body: z.string().min(1).describe("Comment text sent to the requester. Plain text or HTML; visible in the ticket."),
|
|
3786
|
-
attachments:
|
|
3841
|
+
attachments: attachmentsParam("Files to attach to this comment (base64-encoded content).")
|
|
3787
3842
|
}),
|
|
3788
3843
|
annotations: {
|
|
3789
3844
|
readOnlyHint: false,
|
|
@@ -4489,7 +4544,7 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
|
|
|
4489
4544
|
//#endregion
|
|
4490
4545
|
//#region src/transports/stdio.ts
|
|
4491
4546
|
const startStdioTransport = async (server, logger = silentLogger) => {
|
|
4492
|
-
const transport = new StdioServerTransport();
|
|
4547
|
+
const transport = new StdioServerTransport(void 0, void 0, { maxBufferSize: STDIO_MAX_MESSAGE_BYTES });
|
|
4493
4548
|
await server.connect(transport);
|
|
4494
4549
|
logger.info("stdio_transport_ready");
|
|
4495
4550
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fruggr/zendesk-mcp-server",
|
|
3
|
-
"version": "2.20.
|
|
3
|
+
"version": "2.20.2",
|
|
4
4
|
"mcpName": "io.github.fruggr/zendesk-mcp-server",
|
|
5
5
|
"description": "Deep Zendesk MCP server for your AI assistant: search, draft, update and translate Help Center articles and manage Support tickets end to end — comments, triage and image attachments.",
|
|
6
6
|
"type": "module",
|
|
@@ -85,10 +85,10 @@
|
|
|
85
85
|
"remark-rehype": "11.1.2",
|
|
86
86
|
"remark-stringify": "11.0.0",
|
|
87
87
|
"unified": "11.0.5",
|
|
88
|
-
"zod": "4.4
|
|
88
|
+
"zod": "4.5.4"
|
|
89
89
|
},
|
|
90
90
|
"devDependencies": {
|
|
91
|
-
"@biomejs/biome": "2.5.
|
|
91
|
+
"@biomejs/biome": "2.5.12",
|
|
92
92
|
"@semantic-release/changelog": "^7.0.0",
|
|
93
93
|
"@semantic-release/exec": "^7.1.0",
|
|
94
94
|
"@semantic-release/git": "^11.0.0",
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
"msw": "^2.12.13",
|
|
108
108
|
"semantic-release": "^25.0.3",
|
|
109
109
|
"shx": "^0.4.0",
|
|
110
|
-
"tsdown": "^0.
|
|
110
|
+
"tsdown": "^0.23.0",
|
|
111
111
|
"tsx": "^4.21.0",
|
|
112
112
|
"typescript": "^7.0.0",
|
|
113
113
|
"typescript-legacy": "npm:typescript@6.0.3",
|