From d52df606f3371365ce86a60f430e46a520dd544d Mon Sep 17 00:00:00 2001 From: Patrick Maurice Pingol Date: Mon, 8 Jun 2026 23:33:05 +0800 Subject: [PATCH 1/2] Add zendesk_get_attachment tool --- README.md | 9 ++++++ src/tools/index.ts | 80 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/README.md b/README.md index 1ba7b78..f309744 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A Model Context Protocol (MCP) server that provides AI assistants like Claude wi - 🔍 **Advanced Search**: Search tickets using Zendesk's powerful query syntax - 🔗 **Incident Management**: Retrieve and manage linked incident tickets - 🏷️ **Tag Management**: Add and manage ticket tags and metadata +- 📎 **Attachment Fetching**: Retrieve attachment content by URL or numeric ID, with inline image rendering - 🔒 **Secure Authentication**: Uses Zendesk API tokens for secure access - 🚀 **Easy Installation**: Available via npm, npx, or manual setup @@ -128,6 +129,7 @@ For other MCP-compatible clients (Cline, Windsurf, etc.), refer to their documen | `zendesk_add_private_note` | Add internal agent notes | "Add a private note about investigation progress" | | `zendesk_add_public_note` | Add public customer comments | "Reply to customer with solution steps" | | `zendesk_get_linked_incidents` | Get incident tickets linked to problems | "Show incidents related to this problem ticket" | +| `zendesk_get_attachment` | Fetch attachment content by URL or ID | "Show me the attachment from ticket #12345" | ## 💬 Usage Examples @@ -153,6 +155,13 @@ Once configured, you can use natural language with your AI assistant: "Add a private note: 'Customer confirmed the workaround is effective'" ``` +### Attachments +``` +"Show me the screenshot attached to ticket #12345" +"Fetch the attachment at https://your-subdomain.zendesk.com/attachments/token/abc123/?name=image.png" +"Get attachment ID 9876543210" +``` + ### Advanced Queries ``` "Find all problem tickets that have linked incidents" diff --git a/src/tools/index.ts b/src/tools/index.ts index 22b51bc..d6733c3 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -114,6 +114,41 @@ export async function getLinkedIncidents(client: any, ticketId: number): Promise }); } +export async function getAttachment( + client: any, + email: string, + token: string, + urlOrId: string +): Promise<{ data: string; mimeType: string; isImage: boolean }> { + // Resolve a numeric ID to a content_url via the Zendesk client + let fetchUrl = urlOrId; + if (/^\d+$/.test(urlOrId)) { + const meta = await new Promise((resolve, reject) => { + client.attachments.show(parseInt(urlOrId, 10), (error: Error | undefined, _req: any, result: any) => { + if (error) reject(error); + else resolve(result); + }); + }); + fetchUrl = meta.content_url; + } + + const authHeader = `Basic ${Buffer.from(`${email}/token:${token}`).toString("base64")}`; + const res = await fetch(fetchUrl, { + headers: { Authorization: authHeader }, + redirect: "follow", + }); + if (!res.ok) throw new Error(`Attachment fetch failed: ${res.status}`); + + const mimeType = res.headers.get("content-type") ?? "application/octet-stream"; + const isImage = mimeType.startsWith("image/"); + const buffer = await res.arrayBuffer(); + const data = isImage + ? Buffer.from(buffer).toString("base64") + : Buffer.from(buffer).toString("utf-8"); + + return { data, mimeType, isImage }; +} + // Environment-based client for backward compatibility if (!process.env.ZENDESK_EMAIL || !process.env.ZENDESK_TOKEN || !process.env.ZENDESK_SUBDOMAIN) { @@ -459,4 +494,49 @@ export function zenDeskTools(server: McpServer) { } } ); + + server.tool( + "zendesk_get_attachment", + "Fetch an attachment from Zendesk by URL or attachment ID. Returns images as base64, text files as plain text.", + { + url_or_id: z.string().describe( + "Either a full Zendesk attachment URL (e.g. https://shogo.zendesk.com/attachments/token/.../?name=image.png) or a numeric attachment ID" + ), + }, + async ({ url_or_id }) => { + await log(server, "info", `zendesk_get_attachment: fetching ${url_or_id}`); + try { + const { data, mimeType, isImage } = await getAttachment( + client, + process.env.ZENDESK_EMAIL!, + process.env.ZENDESK_TOKEN!, + url_or_id + ); + if (isImage) { + return { + content: [{ + type: "image", + data, + mimeType, + }] + }; + } + return { + content: [{ + type: "text", + text: data, + }] + }; + } catch (error: any) { + await log(server, "error", `zendesk_get_attachment: failed — ${error.message}`); + return { + content: [{ + type: "text", + text: `Error: ${error.message || 'Unknown error occurred'}` + }], + isError: true + }; + } + } + ); } \ No newline at end of file From 70c4f67f9a5d42468f4fd315b1b7a28c86c64f78 Mon Sep 17 00:00:00 2001 From: Patrick Maurice Pingol Date: Mon, 8 Jun 2026 23:49:46 +0800 Subject: [PATCH 2/2] Cap attachment downloads at 15MB --- src/tools/index.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/tools/index.ts b/src/tools/index.ts index d6733c3..860dccf 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -132,6 +132,8 @@ export async function getAttachment( fetchUrl = meta.content_url; } + const MAX_BYTES = 15 * 1024 * 1024; // 15MB + const authHeader = `Basic ${Buffer.from(`${email}/token:${token}`).toString("base64")}`; const res = await fetch(fetchUrl, { headers: { Authorization: authHeader }, @@ -139,12 +141,32 @@ export async function getAttachment( }); if (!res.ok) throw new Error(`Attachment fetch failed: ${res.status}`); + const contentLength = res.headers.get("content-length"); + if (contentLength && parseInt(contentLength, 10) > MAX_BYTES) { + const mb = (parseInt(contentLength, 10) / 1024 / 1024).toFixed(1); + throw new Error(`Attachment too large: ${mb}MB exceeds 15MB limit`); + } + + const chunks: Uint8Array[] = []; + let totalBytes = 0; + const reader = res.body!.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_BYTES) { + await reader.cancel(); + throw new Error(`Attachment too large: exceeds 15MB limit`); + } + chunks.push(value); + } + const mimeType = res.headers.get("content-type") ?? "application/octet-stream"; const isImage = mimeType.startsWith("image/"); - const buffer = await res.arrayBuffer(); + const buffer = Buffer.concat(chunks.map(c => Buffer.from(c))); const data = isImage - ? Buffer.from(buffer).toString("base64") - : Buffer.from(buffer).toString("utf-8"); + ? buffer.toString("base64") + : buffer.toString("utf-8"); return { data, mimeType, isImage }; }