@fruggr/zendesk-mcp-server 2.20.1 → 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 +90 -36
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -129,6 +129,11 @@ const CHARACTER_LIMIT = positiveIntEnv("ZENDESK_CHARACTER_LIMIT", 25e3);
|
|
|
129
129
|
const ARTICLE_RESOURCES_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES", 20);
|
|
130
130
|
const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5242880);
|
|
131
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));
|
|
132
137
|
const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
|
|
133
138
|
const REORDER_CONFIRM_THRESHOLD = positiveIntEnv("ZENDESK_REORDER_CONFIRM_THRESHOLD", 20);
|
|
134
139
|
const getBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2`;
|
|
@@ -3083,7 +3088,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
3083
3088
|
inputSchema: z.object({
|
|
3084
3089
|
article_id: z.number().int().describe(ARTICLE_ID_DESC),
|
|
3085
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."),
|
|
3086
|
-
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.`),
|
|
3087
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.")
|
|
3088
3096
|
}),
|
|
3089
3097
|
annotations: {
|
|
@@ -3163,6 +3171,28 @@ const createSearchTools = (ctx) => {
|
|
|
3163
3171
|
//#endregion
|
|
3164
3172
|
//#region src/tools/tickets.ts
|
|
3165
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
|
+
});
|
|
3166
3196
|
const formatReference = (attachment) => `**${attachment.file_name}** (id ${attachment.id}, ${attachment.content_type}, ${attachment.size} bytes) — ${attachment.content_url}`;
|
|
3167
3197
|
const buildEmbeddedImageBlocks = async (subdomain, token, attachment, reference) => {
|
|
3168
3198
|
const { data, contentType } = await fetchZendeskBinary(subdomain, token, attachment.content_url);
|
|
@@ -3211,38 +3241,52 @@ const fetchAttachmentsByIds = async (subdomain, token, ids) => {
|
|
|
3211
3241
|
}
|
|
3212
3242
|
return attachments;
|
|
3213
3243
|
};
|
|
3214
|
-
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) => {
|
|
3215
3266
|
const blocks = [];
|
|
3216
3267
|
let embeddedCount = 0;
|
|
3217
|
-
|
|
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()) {
|
|
3218
3272
|
const reference = formatReference(attachment);
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
blocks.push(
|
|
3231
|
-
|
|
3232
|
-
text: `${reference} — ${skipReason}`
|
|
3233
|
-
});
|
|
3234
|
-
continue;
|
|
3235
|
-
}
|
|
3236
|
-
try {
|
|
3237
|
-
blocks.push(...await buildEmbeddedImageBlocks(subdomain, token, attachment, reference));
|
|
3238
|
-
embeddedCount += 1;
|
|
3239
|
-
} catch (error) {
|
|
3240
|
-
const reason = error instanceof ZendeskApiError ? `download failed: ${error.status} ${error.statusText}` : "download failed";
|
|
3241
|
-
blocks.push({
|
|
3242
|
-
type: "text",
|
|
3243
|
-
text: `${reference} — ${reason}`
|
|
3244
|
-
});
|
|
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;
|
|
3245
3286
|
}
|
|
3287
|
+
blocks.push(...produced);
|
|
3288
|
+
responseBytes += cost;
|
|
3289
|
+
if (produced.some((block) => block.type === "image")) embeddedCount += 1;
|
|
3246
3290
|
}
|
|
3247
3291
|
return blocks;
|
|
3248
3292
|
};
|
|
@@ -3437,9 +3481,19 @@ const createTicketTools = (ctx) => {
|
|
|
3437
3481
|
const { subdomain, getToken } = ctx;
|
|
3438
3482
|
const attachmentSchema = z.object({
|
|
3439
3483
|
file_name: z.string().min(1).describe("File name, e.g. \"app.log\" or \"screenshot.png\"."),
|
|
3440
|
-
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.`),
|
|
3441
3488
|
content_type: z.string().min(1).default("application/octet-stream").describe("MIME type, e.g. \"text/plain\", \"image/png\", \"application/pdf\".")
|
|
3442
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);
|
|
3443
3497
|
const uploadAttachments = async (token, files) => {
|
|
3444
3498
|
let uploadToken;
|
|
3445
3499
|
for (const file of files) {
|
|
@@ -3602,11 +3656,11 @@ const createTicketTools = (ctx) => {
|
|
|
3602
3656
|
type: "text",
|
|
3603
3657
|
text: `No attachments found on ticket #${ticket_id}.`
|
|
3604
3658
|
}] };
|
|
3605
|
-
const
|
|
3606
|
-
return { content: [{
|
|
3659
|
+
const header = {
|
|
3607
3660
|
type: "text",
|
|
3608
3661
|
text: `# Attachments for ticket #${ticket_id} (${attachments.length} total)`
|
|
3609
|
-
}
|
|
3662
|
+
};
|
|
3663
|
+
return { content: [header, ...await collectAttachmentBlocks(subdomain, token, attachments, blockCost(header) + 1)] };
|
|
3610
3664
|
}
|
|
3611
3665
|
},
|
|
3612
3666
|
{
|
|
@@ -3752,7 +3806,7 @@ const createTicketTools = (ctx) => {
|
|
|
3752
3806
|
inputSchema: z.object({
|
|
3753
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."),
|
|
3754
3808
|
body: z.string().min(1).describe("Note text (internal, agent-only). Plain text or HTML; not shown to the requester."),
|
|
3755
|
-
attachments:
|
|
3809
|
+
attachments: attachmentsParam("Files to attach to this note (base64-encoded content).")
|
|
3756
3810
|
}),
|
|
3757
3811
|
annotations: {
|
|
3758
3812
|
readOnlyHint: false,
|
|
@@ -3784,7 +3838,7 @@ const createTicketTools = (ctx) => {
|
|
|
3784
3838
|
inputSchema: z.object({
|
|
3785
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."),
|
|
3786
3840
|
body: z.string().min(1).describe("Comment text sent to the requester. Plain text or HTML; visible in the ticket."),
|
|
3787
|
-
attachments:
|
|
3841
|
+
attachments: attachmentsParam("Files to attach to this comment (base64-encoded content).")
|
|
3788
3842
|
}),
|
|
3789
3843
|
annotations: {
|
|
3790
3844
|
readOnlyHint: false,
|
|
@@ -4490,7 +4544,7 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
|
|
|
4490
4544
|
//#endregion
|
|
4491
4545
|
//#region src/transports/stdio.ts
|
|
4492
4546
|
const startStdioTransport = async (server, logger = silentLogger) => {
|
|
4493
|
-
const transport = new StdioServerTransport();
|
|
4547
|
+
const transport = new StdioServerTransport(void 0, void 0, { maxBufferSize: STDIO_MAX_MESSAGE_BYTES });
|
|
4494
4548
|
await server.connect(transport);
|
|
4495
4549
|
logger.info("stdio_transport_ready");
|
|
4496
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",
|