@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.
Files changed (2) hide show
  1. package/dist/index.js +90 -36
  2. 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).describe("The file's raw bytes as a base64-encoded string; the server decodes them before upload."),
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 collectAttachmentBlocks = async (subdomain, token, attachments) => {
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
- for (const attachment of attachments) {
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
- if (!attachment.content_type.startsWith("image/")) {
3220
- blocks.push({
3221
- type: "text",
3222
- text: reference
3223
- });
3224
- continue;
3225
- }
3226
- let skipReason = null;
3227
- if (attachment.size > MAX_ATTACHMENT_BYTES) skipReason = `skipped: exceeds ${MAX_ATTACHMENT_MB} MB per-image limit`;
3228
- else if (embeddedCount >= MAX_EMBEDDED_IMAGE_COUNT) skipReason = `skipped: max ${MAX_EMBEDDED_IMAGE_COUNT} embedded images reached`;
3229
- if (skipReason) {
3230
- blocks.push({
3231
- type: "text",
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).base64().describe("File content encoded as base64."),
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 blocks = await collectAttachmentBlocks(subdomain, token, attachments);
3606
- return { content: [{
3659
+ const header = {
3607
3660
  type: "text",
3608
3661
  text: `# Attachments for ticket #${ticket_id} (${attachments.length} total)`
3609
- }, ...blocks] };
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: z.array(attachmentSchema).optional().describe("Files to attach to this note (base64-encoded content).")
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: z.array(attachmentSchema).optional().describe("Files to attach to this comment (base64-encoded content).")
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.1",
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",