@fruggr/zendesk-mcp-server 2.19.0 → 2.20.1
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 +123 -47
- package/package.json +5 -5
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";
|
|
@@ -118,13 +119,13 @@ const createLogger = (level) => {
|
|
|
118
119
|
};
|
|
119
120
|
//#endregion
|
|
120
121
|
//#region src/constants.ts
|
|
121
|
-
const CHARACTER_LIMIT = 25e3;
|
|
122
122
|
const positiveIntEnv = (name, fallback) => {
|
|
123
123
|
const raw = process.env[name];
|
|
124
124
|
if (raw === void 0 || raw.trim() === "") return fallback;
|
|
125
125
|
const parsed = Number(raw);
|
|
126
126
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
127
127
|
};
|
|
128
|
+
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);
|
|
@@ -1214,9 +1215,9 @@ const markdownToHtml = (markdown) => {
|
|
|
1214
1215
|
};
|
|
1215
1216
|
//#endregion
|
|
1216
1217
|
//#region src/utils/formatting.ts
|
|
1217
|
-
const truncateIfNeeded = (text) => {
|
|
1218
|
-
if (text.length <=
|
|
1219
|
-
return `${text.slice(0, CHARACTER_LIMIT)}\n\n--- Response truncated (${text.length} chars, limit ${CHARACTER_LIMIT}).
|
|
1218
|
+
const truncateIfNeeded = (text, advice = "Use pagination or filters to reduce results.") => {
|
|
1219
|
+
if (text.length <= CHARACTER_LIMIT) return text;
|
|
1220
|
+
return `${text.slice(0, CHARACTER_LIMIT)}\n\n--- Response truncated (${text.length} chars, limit ${CHARACTER_LIMIT}). ${advice} ---`;
|
|
1220
1221
|
};
|
|
1221
1222
|
const formatPagination = (meta) => {
|
|
1222
1223
|
const parts = [`Results: ${meta.count}`];
|
|
@@ -1302,8 +1303,14 @@ const formatSlaBlock = (entry) => {
|
|
|
1302
1303
|
for (const m of entry.policy_metrics) lines.push(formatSlaMetric(m));
|
|
1303
1304
|
return `\n\n${lines.join("\n")}`;
|
|
1304
1305
|
};
|
|
1305
|
-
const
|
|
1306
|
-
const
|
|
1306
|
+
const withName = (id, names) => {
|
|
1307
|
+
const n = Number(id);
|
|
1308
|
+
if (n === -1) return "System (-1)";
|
|
1309
|
+
const name = names.get(n);
|
|
1310
|
+
return name ? `${name} (${id})` : String(id);
|
|
1311
|
+
};
|
|
1312
|
+
const formatComment = (comment, authors) => {
|
|
1313
|
+
const lines = [`### ${comment.public ? "Public comment" : "Internal note"} (id ${comment.id}) by ${withName(comment.author_id, authors ?? /* @__PURE__ */ new Map())}`, `*${comment.created_at}*`];
|
|
1307
1314
|
if (comment.attachments?.length) {
|
|
1308
1315
|
const summary = comment.attachments.map((a) => `#${a.id} (${a.content_type})`).join(", ");
|
|
1309
1316
|
lines.push(`Attachments: ${summary}`);
|
|
@@ -1339,12 +1346,6 @@ const AUDIT_FIELD_LABELS = {
|
|
|
1339
1346
|
submitter_id: "submitter",
|
|
1340
1347
|
group_id: "group"
|
|
1341
1348
|
};
|
|
1342
|
-
const withName = (id, names) => {
|
|
1343
|
-
const n = Number(id);
|
|
1344
|
-
if (n === -1) return "System (-1)";
|
|
1345
|
-
const name = names.get(n);
|
|
1346
|
-
return name ? `${name} (${id})` : String(id);
|
|
1347
|
-
};
|
|
1348
1349
|
const renderAuditValue = (field, value, names) => {
|
|
1349
1350
|
if (value === null || value === void 0 || value === "") return "";
|
|
1350
1351
|
const entity = AUDIT_ENTITY_FIELDS[field];
|
|
@@ -1453,9 +1454,9 @@ const formatContentTag = (tag) => `- **${tag.name}** (${tag.id})`;
|
|
|
1453
1454
|
const formatLabel = (label) => `- **${label.name}** (${label.id})`;
|
|
1454
1455
|
const formatUserSegment = (segment) => `- **${segment.name}** (${segment.id}) — ${segment.user_type}${segment.built_in ? " — Built-in" : ""}`;
|
|
1455
1456
|
const formatAttachment = (attachment) => `- **${attachment.file_name}** (${attachment.id}) — ${attachment.content_type} — ${attachment.size} bytes`;
|
|
1456
|
-
const formatList = (items, formatter, meta) => {
|
|
1457
|
+
const formatList = (items, formatter, meta, advice) => {
|
|
1457
1458
|
const text = [meta ? formatPagination(meta) : "", items.map(formatter).join("\n\n")].filter(Boolean).join("\n\n");
|
|
1458
|
-
return truncateIfNeeded(text);
|
|
1459
|
+
return truncateIfNeeded(text, advice);
|
|
1459
1460
|
};
|
|
1460
1461
|
//#endregion
|
|
1461
1462
|
//#region src/utils/pagination.ts
|
|
@@ -1548,7 +1549,7 @@ const fetchArticleMarkdown = async (subdomain, token, id, locale) => {
|
|
|
1548
1549
|
"",
|
|
1549
1550
|
htmlToMarkdown(article.body)
|
|
1550
1551
|
].join("\n");
|
|
1551
|
-
return truncateIfNeeded(text);
|
|
1552
|
+
return truncateIfNeeded(text, "This resource takes no parameters; read a long article one part at a time with get_article_outline then get_article_section.");
|
|
1552
1553
|
};
|
|
1553
1554
|
/**
|
|
1554
1555
|
* Build an article-resources provider. `listPromoted` holds a memoized-promise
|
|
@@ -1794,7 +1795,7 @@ const formatTopology = (data) => {
|
|
|
1794
1795
|
"## Permission groups",
|
|
1795
1796
|
...renderAdminSection(data.permissionGroups.map(formatPermissionGroup), data.permissionGroupsDenied, "_Unavailable: listing permission groups requires Guide-admin / Help Center manager rights, which this token lacks (HTTP 403). To create or edit an article, reuse the permission_group_id of an existing article (get_article)._")
|
|
1796
1797
|
].join("\n");
|
|
1797
|
-
return truncateIfNeeded(text);
|
|
1798
|
+
return truncateIfNeeded(text, "This resource takes no parameters; walk the tree with list_categories and list_sections instead.");
|
|
1798
1799
|
};
|
|
1799
1800
|
/**
|
|
1800
1801
|
* Build a topology provider holding a memoized-promise cache (TTL
|
|
@@ -2025,6 +2026,10 @@ const renderGapVerdict = (report, gapCount, unclassified) => {
|
|
|
2025
2026
|
const allClear = `No gaps: all ${scanned.categories} category/ies and ${scanned.sections} section(s) scanned have a published "${locale}" translation.`;
|
|
2026
2027
|
return unclassified > 0 ? `${allClear} This is not a clean bill of health for the whole tree: ${unclassified} other node(s) could not be classified — see the note below.` : allClear;
|
|
2027
2028
|
};
|
|
2029
|
+
const gapAdvice = (report) => {
|
|
2030
|
+
if (!report.categoryScoped) return "find_translation_gaps takes no pagination parameter; narrow the audit to one branch of the tree with category_id instead.";
|
|
2031
|
+
return report.listingIncomplete ? "find_translation_gaps takes no pagination parameter and this category holds more sections than one page, so the section listing is incomplete: list the rest with list_sections (category_id, following its cursor) and read them with list_section_translations." : "find_translation_gaps takes no pagination parameter, and this audit is already scoped to one category: fix the nodes above with set_category_translation / set_section_translation, then re-run it.";
|
|
2032
|
+
};
|
|
2028
2033
|
const renderGapReport = (report) => {
|
|
2029
2034
|
const { locale, categoryGaps, sectionGaps, scanned, found } = report;
|
|
2030
2035
|
const gapCount = categoryGaps.length + sectionGaps.length;
|
|
@@ -2040,8 +2045,8 @@ const renderGapReport = (report) => {
|
|
|
2040
2045
|
"",
|
|
2041
2046
|
renderGapVerdict(report, gapCount, unclassified),
|
|
2042
2047
|
...unclassified > 0 ? ["", `_Note: ${unclassified} of ${totalFound} node(s) came back without the \`translations\` sideload, so their state is unknown and none of them is reported above (covered: ${scanned.categories}/${found.categories} categories, ${scanned.sections}/${found.sections} sections). Read one of them with list_category_translations / list_section_translations, which query the node directly._`] : [],
|
|
2043
|
-
...report.listingIncomplete ? ["", `_Note: this Help Center has more than 100 categories or sections, so only the first page of each was considered. Narrow the scan with category_id to audit the rest._`] : []
|
|
2044
|
-
].join("\n"));
|
|
2048
|
+
...report.listingIncomplete ? ["", report.categoryScoped ? `_Note: this category holds more than 100 sections, so only the first page was considered. List the rest with list_sections (category_id, following its cursor) and read them with list_section_translations._` : `_Note: this Help Center has more than 100 categories or sections, so only the first page of each was considered. Narrow the scan with category_id to audit the rest._`] : []
|
|
2049
|
+
].join("\n"), gapAdvice(report));
|
|
2045
2050
|
};
|
|
2046
2051
|
const largeArticleHint = (body, sectionCount) => {
|
|
2047
2052
|
if (body.length < 3e3 && sectionCount < 4) return null;
|
|
@@ -2203,7 +2208,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2203
2208
|
const text = (largeArticleHint(article.body, parseSections(article.body).length) ?? "") + formatArticle(article) + `\n\n**Available translations**: ${translations.map((t) => t.locale).join(", ")}`;
|
|
2204
2209
|
return { content: [{
|
|
2205
2210
|
type: "text",
|
|
2206
|
-
text: truncateIfNeeded(text)
|
|
2211
|
+
text: truncateIfNeeded(text, "get_article takes no pagination parameter; read a long article one part at a time with get_article_outline then get_article_section.")
|
|
2207
2212
|
}] };
|
|
2208
2213
|
}
|
|
2209
2214
|
},
|
|
@@ -2338,7 +2343,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2338
2343
|
const note = scanCostNote(truncated, pagesScanned, cost);
|
|
2339
2344
|
return { content: [{
|
|
2340
2345
|
type: "text",
|
|
2341
|
-
text: truncateIfNeeded(`${header}\n\n${body}${note}
|
|
2346
|
+
text: truncateIfNeeded(`${header}\n\n${body}${note}`, "list_promoted_articles takes no parameters, so this listing cannot be narrowed from the call; read a single article with get_article.")
|
|
2342
2347
|
}] };
|
|
2343
2348
|
}
|
|
2344
2349
|
},
|
|
@@ -2361,7 +2366,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2361
2366
|
const translations = await listTranslations(subdomain, token, article_id);
|
|
2362
2367
|
return { content: [{
|
|
2363
2368
|
type: "text",
|
|
2364
|
-
text: formatList(translations, formatTranslationSummary)
|
|
2369
|
+
text: formatList(translations, formatTranslationSummary, void 0, "list_article_translations takes only article_id, so this listing cannot be narrowed from the call; read one locale in full with get_article.")
|
|
2365
2370
|
}] };
|
|
2366
2371
|
}
|
|
2367
2372
|
},
|
|
@@ -2447,7 +2452,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2447
2452
|
const translations = await listNodeTranslations(subdomain, token, "sections", section_id);
|
|
2448
2453
|
return { content: [{
|
|
2449
2454
|
type: "text",
|
|
2450
|
-
text: formatList(translations, formatNodeTranslationSummary)
|
|
2455
|
+
text: formatList(translations, formatNodeTranslationSummary, void 0, "list_section_translations takes only section_id, so this listing cannot be narrowed from the call; write one locale with set_section_translation.")
|
|
2451
2456
|
}] };
|
|
2452
2457
|
}
|
|
2453
2458
|
},
|
|
@@ -2470,7 +2475,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2470
2475
|
const translations = await listNodeTranslations(subdomain, token, "categories", category_id);
|
|
2471
2476
|
return { content: [{
|
|
2472
2477
|
type: "text",
|
|
2473
|
-
text: formatList(translations, formatNodeTranslationSummary)
|
|
2478
|
+
text: formatList(translations, formatNodeTranslationSummary, void 0, "list_category_translations takes only category_id, so this listing cannot be narrowed from the call; write one locale with set_category_translation.")
|
|
2474
2479
|
}] };
|
|
2475
2480
|
}
|
|
2476
2481
|
},
|
|
@@ -2522,7 +2527,8 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2522
2527
|
categories: allCategories.length,
|
|
2523
2528
|
sections: allSections.length
|
|
2524
2529
|
},
|
|
2525
|
-
listingIncomplete: categoryScope.hasMore || extractPaginationMeta(sectionsRes, allSections.length).has_more
|
|
2530
|
+
listingIncomplete: categoryScope.hasMore || extractPaginationMeta(sectionsRes, allSections.length).has_more,
|
|
2531
|
+
categoryScoped: category_id !== void 0
|
|
2526
2532
|
})
|
|
2527
2533
|
}] };
|
|
2528
2534
|
}
|
|
@@ -2609,7 +2615,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2609
2615
|
}
|
|
2610
2616
|
return { content: [{
|
|
2611
2617
|
type: "text",
|
|
2612
|
-
text: formatList(response.permission_groups ?? [], formatPermissionGroup)
|
|
2618
|
+
text: formatList(response.permission_groups ?? [], formatPermissionGroup, void 0, "list_permission_groups takes no parameters, so this listing cannot be narrowed from the call.")
|
|
2613
2619
|
}] };
|
|
2614
2620
|
}
|
|
2615
2621
|
},
|
|
@@ -2847,7 +2853,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2847
2853
|
const response = await helpCenterGet(subdomain, token, "/articles/labels");
|
|
2848
2854
|
return { content: [{
|
|
2849
2855
|
type: "text",
|
|
2850
|
-
text: formatList(response.labels ?? [], formatLabel)
|
|
2856
|
+
text: formatList(response.labels ?? [], formatLabel, void 0, "list_labels takes no parameters, so this listing cannot be narrowed from the call.")
|
|
2851
2857
|
}] };
|
|
2852
2858
|
}
|
|
2853
2859
|
},
|
|
@@ -2875,7 +2881,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2875
2881
|
}
|
|
2876
2882
|
return { content: [{
|
|
2877
2883
|
type: "text",
|
|
2878
|
-
text: formatList(response.user_segments ?? [], formatUserSegment)
|
|
2884
|
+
text: formatList(response.user_segments ?? [], formatUserSegment, void 0, "list_user_segments takes no parameters, so this listing cannot be narrowed from the call.")
|
|
2879
2885
|
}] };
|
|
2880
2886
|
}
|
|
2881
2887
|
},
|
|
@@ -2902,7 +2908,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2902
2908
|
}] };
|
|
2903
2909
|
return { content: [{
|
|
2904
2910
|
type: "text",
|
|
2905
|
-
text: formatList(attachments, formatAttachment)
|
|
2911
|
+
text: formatList(attachments, formatAttachment, void 0, "list_article_attachments takes only article_id, so this listing cannot be narrowed from the call.")
|
|
2906
2912
|
}] };
|
|
2907
2913
|
}
|
|
2908
2914
|
},
|
|
@@ -2981,7 +2987,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2981
2987
|
].join("\n");
|
|
2982
2988
|
return { content: [{
|
|
2983
2989
|
type: "text",
|
|
2984
|
-
text: truncateIfNeeded(text)
|
|
2990
|
+
text: truncateIfNeeded(text, format === "markdown" ? "get_article_section takes no pagination parameter, and this single section already exceeds the limit." : "get_article_section takes no pagination parameter; request this section as format=\"markdown\", which renders the same content more compactly than HTML.")
|
|
2985
2991
|
}] };
|
|
2986
2992
|
}
|
|
2987
2993
|
},
|
|
@@ -3185,6 +3191,16 @@ const fetchAllTicketComments = async (subdomain, token, ticketId) => {
|
|
|
3185
3191
|
}
|
|
3186
3192
|
return all;
|
|
3187
3193
|
};
|
|
3194
|
+
const commentPageMeta = (response, itemCount) => extractPaginationMeta({
|
|
3195
|
+
...response.meta && { meta: response.meta },
|
|
3196
|
+
...response.count !== void 0 && { count: response.count }
|
|
3197
|
+
}, itemCount);
|
|
3198
|
+
const offsetPageNote = (response) => response.meta?.after_cursor == null && response.next_page != null ? "\n\n> ⚠ Zendesk paginated this response by offset rather than by cursor, so more comments exist beyond this page and no cursor leads to them. This tool pages by cursor only, and no parameter it accepts reaches the rest: read the remaining comments in Zendesk directly." : "";
|
|
3199
|
+
const commentPageOrder = (comments, sortOrder) => {
|
|
3200
|
+
const first = comments[0]?.created_at ?? "";
|
|
3201
|
+
const last = comments.at(-1)?.created_at ?? "";
|
|
3202
|
+
return (first === last ? sortOrder === "desc" : first > last) ? "newest first" : "oldest first";
|
|
3203
|
+
};
|
|
3188
3204
|
const fetchAttachmentsByIds = async (subdomain, token, ids) => {
|
|
3189
3205
|
const attachments = [];
|
|
3190
3206
|
for (const id of ids) try {
|
|
@@ -3327,21 +3343,29 @@ const collectAuditIds = (audits) => {
|
|
|
3327
3343
|
groupIds: [...groupIds]
|
|
3328
3344
|
};
|
|
3329
3345
|
};
|
|
3346
|
+
const resolveEntityNames = async (subdomain, token, path, key, ids) => {
|
|
3347
|
+
const map = /* @__PURE__ */ new Map();
|
|
3348
|
+
for (const batch of chunk(ids, 100)) try {
|
|
3349
|
+
const res = await zendeskGet(subdomain, token, path, { ids: batch.join(",") });
|
|
3350
|
+
for (const entity of res[key] ?? []) map.set(entity.id, entity.name);
|
|
3351
|
+
} catch {}
|
|
3352
|
+
return map;
|
|
3353
|
+
};
|
|
3354
|
+
const resolveUserNames = (subdomain, token, ids) => resolveEntityNames(subdomain, token, "/users/show_many", "users", ids);
|
|
3330
3355
|
const resolveAuditNames = async (subdomain, token, userIds, groupIds) => {
|
|
3331
|
-
const
|
|
3332
|
-
const map = /* @__PURE__ */ new Map();
|
|
3333
|
-
for (const batch of chunk(ids, 100)) try {
|
|
3334
|
-
const res = await zendeskGet(subdomain, token, path, { ids: batch.join(",") });
|
|
3335
|
-
for (const entity of res[key] ?? []) map.set(entity.id, entity.name);
|
|
3336
|
-
} catch {}
|
|
3337
|
-
return map;
|
|
3338
|
-
};
|
|
3339
|
-
const [users, groups] = await Promise.all([resolve("/users/show_many", "users", userIds), resolve("/groups/show_many", "groups", groupIds)]);
|
|
3356
|
+
const [users, groups] = await Promise.all([resolveUserNames(subdomain, token, userIds), resolveEntityNames(subdomain, token, "/groups/show_many", "groups", groupIds)]);
|
|
3340
3357
|
return {
|
|
3341
3358
|
users,
|
|
3342
3359
|
groups
|
|
3343
3360
|
};
|
|
3344
3361
|
};
|
|
3362
|
+
const resolveCommentAuthors = async (subdomain, token, comments, sideloaded = []) => {
|
|
3363
|
+
const authors = new Map(sideloaded.map((user) => [user.id, user.name]));
|
|
3364
|
+
const missing = [...new Set(comments.map((comment) => comment.author_id))].filter((id) => id > 0 && !authors.has(id));
|
|
3365
|
+
if (missing.length === 0) return authors;
|
|
3366
|
+
for (const [id, name] of await resolveUserNames(subdomain, token, missing)) authors.set(id, name);
|
|
3367
|
+
return authors;
|
|
3368
|
+
};
|
|
3345
3369
|
const DIFF_SKIP_KEYS = /* @__PURE__ */ new Set([
|
|
3346
3370
|
"comment",
|
|
3347
3371
|
"fields",
|
|
@@ -3431,10 +3455,10 @@ const createTicketTools = (ctx) => {
|
|
|
3431
3455
|
namespace: "tickets",
|
|
3432
3456
|
readOnly: true,
|
|
3433
3457
|
title: "Get Zendesk Ticket",
|
|
3434
|
-
description: "Retrieve a Zendesk ticket by ID, including its live SLA state (per-metric stage and breach countdown) when an SLA policy applies, plus its comments if requested. Returns ticket details (subject, status, priority, assignee, tags, description) and optionally all comments/internal notes. The per-ticket Show endpoint exposes no SLA, so the SLA block is resolved via a scoped search and may be absent for a very high-volume requester or a just-updated ticket; SLA targets and policy conditions live in list_sla_policies. This returns the ticket as it stands now; for the history of changes behind that state (who changed what, and when), use get_ticket_history.",
|
|
3458
|
+
description: "Retrieve a Zendesk ticket by ID, including its live SLA state (per-metric stage and breach countdown) when an SLA policy applies, plus its comments if requested. Returns ticket details (subject, status, priority, assignee, tags, description) and optionally all comments/internal notes. The per-ticket Show endpoint exposes no SLA, so the SLA block is resolved via a scoped search and may be absent for a very high-volume requester or a just-updated ticket; SLA targets and policy conditions live in list_sla_policies. This returns the ticket as it stands now; for the history of changes behind that state (who changed what, and when), use get_ticket_history. The comment thread is appended in one block — the first page of comments Zendesk returns, cut past the response character limit — so on a long ticket read it with list_ticket_comments, which pages the comments and returns the newest first.",
|
|
3435
3459
|
inputSchema: z.object({
|
|
3436
3460
|
ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket to fetch. Obtain it from search_tickets or list_tickets."),
|
|
3437
|
-
include_comments: z.boolean().default(false).describe("When true, appends the full public comment and internal note thread to the response. Defaults to false to keep the payload small; enable it when you need the conversation, not just the ticket fields.")
|
|
3461
|
+
include_comments: z.boolean().default(false).describe("When true, appends the full public comment and internal note thread to the response. Defaults to false to keep the payload small; enable it when you need the conversation, not just the ticket fields. On a long thread prefer list_ticket_comments — this flag appends one unpaginated block, so comments past Zendesk's first page are absent and the rest is cut at the response character limit.")
|
|
3438
3462
|
}),
|
|
3439
3463
|
annotations: {
|
|
3440
3464
|
readOnlyHint: true,
|
|
@@ -3448,12 +3472,17 @@ const createTicketTools = (ctx) => {
|
|
|
3448
3472
|
const { ticket } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}`);
|
|
3449
3473
|
let text = formatTicket(ticket) + formatSlaBlock(await fetchTicketSla(subdomain, token, ticket));
|
|
3450
3474
|
if (include_comments) {
|
|
3451
|
-
const { comments } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments`, {
|
|
3452
|
-
|
|
3475
|
+
const { comments, users } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments`, {
|
|
3476
|
+
include: "users",
|
|
3477
|
+
include_inline_images: "true"
|
|
3478
|
+
});
|
|
3479
|
+
const authors = await resolveCommentAuthors(subdomain, token, comments ?? [], users);
|
|
3480
|
+
text += `\n\n---\n# Comments\n\n${(comments ?? []).map((comment) => formatComment(comment, authors)).join("\n\n")}`;
|
|
3453
3481
|
}
|
|
3482
|
+
const advice = include_comments ? `get_ticket appends the thread as one unpaginated block; read it page by page with list_ticket_comments (ticket_id: ${ticket_id}, sort_order: "desc") to get the newest comments first.` : "get_ticket takes no pagination or filter parameters, so this response cannot be narrowed from the call.";
|
|
3454
3483
|
return { content: [{
|
|
3455
3484
|
type: "text",
|
|
3456
|
-
text: truncateIfNeeded(text)
|
|
3485
|
+
text: truncateIfNeeded(text, advice)
|
|
3457
3486
|
}] };
|
|
3458
3487
|
}
|
|
3459
3488
|
},
|
|
@@ -3462,7 +3491,7 @@ const createTicketTools = (ctx) => {
|
|
|
3462
3491
|
namespace: "tickets",
|
|
3463
3492
|
readOnly: true,
|
|
3464
3493
|
title: "Get Zendesk Ticket History",
|
|
3465
|
-
description: "Read a ticket's change history — its audit trail — as a chronological, oldest-first timeline of who changed what and when. Each entry shows the actor (name and id) and the channel, then the field changes that update carried (status, priority, assignee, group, tags, custom fields) as before → after, with assignee/requester/group ids resolved to names. Comments appear as one-line presence markers (public comment vs internal note added), not their text — fetch the bodies with get_ticket(include_comments=true). Purely system-generated notification events (trigger emails, collaborator/CC notifications, pushes) are filtered out — note this filters notification delivery, not CC-list edits, which are shown as changes — and an update carrying only such events produces no entry, so the timeline stays a readable narrative rather than a raw log. Use it to answer \"what happened on this ticket?\", \"why was it reassigned?\" or \"when did it go to pending?\", reading oldest-first so the founding context is not missed. Read-only, and cursor-paginated oldest-first: pass the returned cursor to page a long-lived ticket toward its most recent changes.",
|
|
3494
|
+
description: "Read a ticket's change history — its audit trail — as a chronological, oldest-first timeline of who changed what and when. Each entry shows the actor (name and id) and the channel, then the field changes that update carried (status, priority, assignee, group, tags, custom fields) as before → after, with assignee/requester/group ids resolved to names. Comments appear as one-line presence markers (public comment vs internal note added), not their text — fetch the bodies with list_ticket_comments (or get_ticket(include_comments=true) for a short thread). Purely system-generated notification events (trigger emails, collaborator/CC notifications, pushes) are filtered out — note this filters notification delivery, not CC-list edits, which are shown as changes — and an update carrying only such events produces no entry, so the timeline stays a readable narrative rather than a raw log. Use it to answer \"what happened on this ticket?\", \"why was it reassigned?\" or \"when did it go to pending?\", reading oldest-first so the founding context is not missed. Read-only, and cursor-paginated oldest-first: pass the returned cursor to page a long-lived ticket toward its most recent changes.",
|
|
3466
3495
|
inputSchema: z.object({
|
|
3467
3496
|
ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket whose change history to read. Obtain it from search_tickets or list_tickets."),
|
|
3468
3497
|
page_size: z.number().int().min(1).max(100).default(100).describe("Audits (ticket updates) per page (1-100, default 100). Each audit is one update to the ticket and may expand to several change lines; audits carrying only system events are dropped, so a page can render fewer entries than this."),
|
|
@@ -3502,6 +3531,53 @@ const createTicketTools = (ctx) => {
|
|
|
3502
3531
|
}] };
|
|
3503
3532
|
}
|
|
3504
3533
|
},
|
|
3534
|
+
{
|
|
3535
|
+
name: "list_ticket_comments",
|
|
3536
|
+
namespace: "tickets",
|
|
3537
|
+
readOnly: true,
|
|
3538
|
+
title: "List Zendesk Ticket Comments",
|
|
3539
|
+
description: "Read a ticket's conversation — public replies and internal notes with their full bodies — one cursor-paginated page at a time, newest comment first. Each entry carries the comment id, the author resolved to a name, the timestamp, whether it is public or internal, and the ids of any attached files. Prefer this over get_ticket(include_comments=true) whenever a thread is long or you only need the latest exchange: get_ticket appends the thread as one unpaginated block and cuts it past the response character limit, which drops the most recent comments first. Keep sort_order \"desc\" (the default) to read the latest reply first and follow the returned cursor to walk further back in time, or pass \"asc\" to replay the conversation forward from the ticket's opening description. For who changed which field and when — without comment bodies — use get_ticket_history; to download the attached files themselves, pass the attachment ids shown here to get_ticket_attachments.",
|
|
3540
|
+
inputSchema: z.object({
|
|
3541
|
+
ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket whose conversation to read. Obtain it from search_tickets or list_tickets."),
|
|
3542
|
+
sort_order: z.enum(["asc", "desc"]).default("desc").describe("Chronological direction of the page. \"desc\" (the default) starts at the most recent comment and walks backward in time, which is what you want to see the latest reply; \"asc\" replays the conversation forward, starting from the ticket's opening description — that first comment therefore lands on the last page under \"desc\"."),
|
|
3543
|
+
page_size: z.number().int().min(1).max(100).default(20).describe("Comments per page (1-100, default 20). The default is deliberately small because comment bodies are long and a bigger page risks being cut short by the response character limit; follow the returned cursor rather than raising it."),
|
|
3544
|
+
cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page. Zendesk issues it for the ordering that response used, so after changing sort_order drop the cursor and start again from the first page.")
|
|
3545
|
+
}),
|
|
3546
|
+
annotations: {
|
|
3547
|
+
readOnlyHint: true,
|
|
3548
|
+
destructiveHint: false,
|
|
3549
|
+
idempotentHint: true,
|
|
3550
|
+
openWorldHint: true
|
|
3551
|
+
},
|
|
3552
|
+
handler: async (params) => {
|
|
3553
|
+
const { ticket_id, sort_order, page_size, cursor } = params;
|
|
3554
|
+
const token = await getToken();
|
|
3555
|
+
const response = await zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments`, {
|
|
3556
|
+
...buildCursorParams(page_size, cursor),
|
|
3557
|
+
sort: sort_order === "desc" ? "-created_at" : "created_at",
|
|
3558
|
+
include: "users",
|
|
3559
|
+
include_inline_images: "true"
|
|
3560
|
+
});
|
|
3561
|
+
const comments = response.comments ?? [];
|
|
3562
|
+
const meta = commentPageMeta(response, comments.length);
|
|
3563
|
+
const offsetNote = offsetPageNote(response);
|
|
3564
|
+
if (comments.length === 0) return { content: [{
|
|
3565
|
+
type: "text",
|
|
3566
|
+
text: `${meta.has_more ? `No comments on this page of ticket #${ticket_id}. More available (cursor: ${meta.after_cursor}).` : `No comments to show for ticket #${ticket_id}.`}${offsetNote}`
|
|
3567
|
+
}] };
|
|
3568
|
+
const authors = await resolveCommentAuthors(subdomain, token, comments, response.users);
|
|
3569
|
+
const body = comments.map((comment) => formatComment(comment, authors)).join("\n\n");
|
|
3570
|
+
const text = `${[
|
|
3571
|
+
`# Comments on ticket #${ticket_id} (${commentPageOrder(comments, sort_order)})`,
|
|
3572
|
+
formatPagination(meta),
|
|
3573
|
+
body
|
|
3574
|
+
].join("\n\n")}${offsetNote}`;
|
|
3575
|
+
return { content: [{
|
|
3576
|
+
type: "text",
|
|
3577
|
+
text: truncateIfNeeded(text, page_size > 1 ? `The comments cut here are not reachable through the cursor, which points past this whole page: re-issue with a page_size smaller than ${page_size} to read them.` : "This single comment is longer than the response character limit, so no page small enough exists: read it in Zendesk directly.")
|
|
3578
|
+
}] };
|
|
3579
|
+
}
|
|
3580
|
+
},
|
|
3505
3581
|
{
|
|
3506
3582
|
name: "get_ticket_attachments",
|
|
3507
3583
|
namespace: "tickets",
|
|
@@ -3510,7 +3586,7 @@ const createTicketTools = (ctx) => {
|
|
|
3510
3586
|
description: "Retrieve ticket attachments. Images are embedded inline; other files are listed as text references.",
|
|
3511
3587
|
inputSchema: z.object({
|
|
3512
3588
|
ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket whose attachments to fetch. Obtain it from search_tickets or list_tickets."),
|
|
3513
|
-
attachment_ids: z.array(z.number().int()).optional().describe("Attachment IDs to fetch directly (e.g. extracted from a previous get_ticket(include_comments=true) call). When provided, skips the comments fetch entirely. When omitted, all attachments of the ticket are returned.")
|
|
3589
|
+
attachment_ids: z.array(z.number().int()).optional().describe("Attachment IDs to fetch directly (e.g. extracted from a previous list_ticket_comments or get_ticket(include_comments=true) call). When provided, skips the comments fetch entirely. When omitted, all attachments of the ticket are returned.")
|
|
3514
3590
|
}),
|
|
3515
3591
|
annotations: {
|
|
3516
3592
|
readOnlyHint: true,
|
|
@@ -3778,7 +3854,7 @@ const createTicketTools = (ctx) => {
|
|
|
3778
3854
|
const text = incidents.length > 0 ? `# Incidents linked to problem #${problem_id}\n\n${incidents.map(formatTicket).join("\n\n")}` : `No incidents linked to problem #${problem_id}.`;
|
|
3779
3855
|
return { content: [{
|
|
3780
3856
|
type: "text",
|
|
3781
|
-
text: truncateIfNeeded(text)
|
|
3857
|
+
text: truncateIfNeeded(text, "get_linked_incidents takes no pagination or filter parameters, so this response cannot be narrowed from the call.")
|
|
3782
3858
|
}] };
|
|
3783
3859
|
}
|
|
3784
3860
|
},
|
|
@@ -4010,7 +4086,7 @@ const createTicketTools = (ctx) => {
|
|
|
4010
4086
|
const [{ ticket: before }, { result }] = await Promise.all([zendeskGet(subdomain, token, `/tickets/${ticket_id}`), zendeskGet(subdomain, token, `/tickets/${ticket_id}/macros/${macro_id}/apply`)]);
|
|
4011
4087
|
return { content: [{
|
|
4012
4088
|
type: "text",
|
|
4013
|
-
text: truncateIfNeeded(formatMacroPreviewDiff(ticket_id, macro_id, before, result))
|
|
4089
|
+
text: truncateIfNeeded(formatMacroPreviewDiff(ticket_id, macro_id, before, result), "preview_macro_diff takes no pagination or filter parameters; read the macro on its own with list_macros to see every action it carries.")
|
|
4014
4090
|
}] };
|
|
4015
4091
|
}
|
|
4016
4092
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fruggr/zendesk-mcp-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.20.1",
|
|
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",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"engines": {
|
|
70
70
|
"node": ">=20"
|
|
71
71
|
},
|
|
72
|
-
"packageManager": "pnpm@11.
|
|
72
|
+
"packageManager": "pnpm@11.25.0+sha512.5cde925b4f075f725eb71fbae18a42ffe784524789f19b61c731cb8721ec28aaee160e01a8d5af4fedb2a42cdbf300efe23db356b0d4a17b4d63e11f8ab7c956",
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
75
75
|
"cheerio": "1.2.0",
|
|
@@ -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",
|