@fruggr/zendesk-mcp-server 2.18.0 → 2.20.0
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 +232 -55
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -118,13 +118,13 @@ const createLogger = (level) => {
|
|
|
118
118
|
};
|
|
119
119
|
//#endregion
|
|
120
120
|
//#region src/constants.ts
|
|
121
|
-
const CHARACTER_LIMIT = 25e3;
|
|
122
121
|
const positiveIntEnv = (name, fallback) => {
|
|
123
122
|
const raw = process.env[name];
|
|
124
123
|
if (raw === void 0 || raw.trim() === "") return fallback;
|
|
125
124
|
const parsed = Number(raw);
|
|
126
125
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
127
126
|
};
|
|
127
|
+
const CHARACTER_LIMIT = positiveIntEnv("ZENDESK_CHARACTER_LIMIT", 25e3);
|
|
128
128
|
const ARTICLE_RESOURCES_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES", 20);
|
|
129
129
|
const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5242880);
|
|
130
130
|
const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("ZENDESK_MAX_EMBEDDED_IMAGES", 10);
|
|
@@ -281,7 +281,6 @@ const startBrowserAuth = (config, logger = silentLogger) => {
|
|
|
281
281
|
});
|
|
282
282
|
const requestedPort = config.callbackPort ?? 27439;
|
|
283
283
|
const onStartError = (err) => {
|
|
284
|
-
clearTimeout(authTimeout);
|
|
285
284
|
const code = err.code;
|
|
286
285
|
logger.error("oauth_callback_listen_failed", {
|
|
287
286
|
port: requestedPort,
|
|
@@ -1215,9 +1214,9 @@ const markdownToHtml = (markdown) => {
|
|
|
1215
1214
|
};
|
|
1216
1215
|
//#endregion
|
|
1217
1216
|
//#region src/utils/formatting.ts
|
|
1218
|
-
const truncateIfNeeded = (text) => {
|
|
1219
|
-
if (text.length <=
|
|
1220
|
-
return `${text.slice(0, CHARACTER_LIMIT)}\n\n--- Response truncated (${text.length} chars, limit ${CHARACTER_LIMIT}).
|
|
1217
|
+
const truncateIfNeeded = (text, advice = "Use pagination or filters to reduce results.") => {
|
|
1218
|
+
if (text.length <= CHARACTER_LIMIT) return text;
|
|
1219
|
+
return `${text.slice(0, CHARACTER_LIMIT)}\n\n--- Response truncated (${text.length} chars, limit ${CHARACTER_LIMIT}). ${advice} ---`;
|
|
1221
1220
|
};
|
|
1222
1221
|
const formatPagination = (meta) => {
|
|
1223
1222
|
const parts = [`Results: ${meta.count}`];
|
|
@@ -1303,8 +1302,14 @@ const formatSlaBlock = (entry) => {
|
|
|
1303
1302
|
for (const m of entry.policy_metrics) lines.push(formatSlaMetric(m));
|
|
1304
1303
|
return `\n\n${lines.join("\n")}`;
|
|
1305
1304
|
};
|
|
1306
|
-
const
|
|
1307
|
-
const
|
|
1305
|
+
const withName = (id, names) => {
|
|
1306
|
+
const n = Number(id);
|
|
1307
|
+
if (n === -1) return "System (-1)";
|
|
1308
|
+
const name = names.get(n);
|
|
1309
|
+
return name ? `${name} (${id})` : String(id);
|
|
1310
|
+
};
|
|
1311
|
+
const formatComment = (comment, authors) => {
|
|
1312
|
+
const lines = [`### ${comment.public ? "Public comment" : "Internal note"} (id ${comment.id}) by ${withName(comment.author_id, authors ?? /* @__PURE__ */ new Map())}`, `*${comment.created_at}*`];
|
|
1308
1313
|
if (comment.attachments?.length) {
|
|
1309
1314
|
const summary = comment.attachments.map((a) => `#${a.id} (${a.content_type})`).join(", ");
|
|
1310
1315
|
lines.push(`Attachments: ${summary}`);
|
|
@@ -1340,12 +1345,6 @@ const AUDIT_FIELD_LABELS = {
|
|
|
1340
1345
|
submitter_id: "submitter",
|
|
1341
1346
|
group_id: "group"
|
|
1342
1347
|
};
|
|
1343
|
-
const withName = (id, names) => {
|
|
1344
|
-
const n = Number(id);
|
|
1345
|
-
if (n === -1) return "System (-1)";
|
|
1346
|
-
const name = names.get(n);
|
|
1347
|
-
return name ? `${name} (${id})` : String(id);
|
|
1348
|
-
};
|
|
1349
1348
|
const renderAuditValue = (field, value, names) => {
|
|
1350
1349
|
if (value === null || value === void 0 || value === "") return "";
|
|
1351
1350
|
const entity = AUDIT_ENTITY_FIELDS[field];
|
|
@@ -1454,9 +1453,9 @@ const formatContentTag = (tag) => `- **${tag.name}** (${tag.id})`;
|
|
|
1454
1453
|
const formatLabel = (label) => `- **${label.name}** (${label.id})`;
|
|
1455
1454
|
const formatUserSegment = (segment) => `- **${segment.name}** (${segment.id}) — ${segment.user_type}${segment.built_in ? " — Built-in" : ""}`;
|
|
1456
1455
|
const formatAttachment = (attachment) => `- **${attachment.file_name}** (${attachment.id}) — ${attachment.content_type} — ${attachment.size} bytes`;
|
|
1457
|
-
const formatList = (items, formatter, meta) => {
|
|
1456
|
+
const formatList = (items, formatter, meta, advice) => {
|
|
1458
1457
|
const text = [meta ? formatPagination(meta) : "", items.map(formatter).join("\n\n")].filter(Boolean).join("\n\n");
|
|
1459
|
-
return truncateIfNeeded(text);
|
|
1458
|
+
return truncateIfNeeded(text, advice);
|
|
1460
1459
|
};
|
|
1461
1460
|
//#endregion
|
|
1462
1461
|
//#region src/utils/pagination.ts
|
|
@@ -1549,7 +1548,7 @@ const fetchArticleMarkdown = async (subdomain, token, id, locale) => {
|
|
|
1549
1548
|
"",
|
|
1550
1549
|
htmlToMarkdown(article.body)
|
|
1551
1550
|
].join("\n");
|
|
1552
|
-
return truncateIfNeeded(text);
|
|
1551
|
+
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.");
|
|
1553
1552
|
};
|
|
1554
1553
|
/**
|
|
1555
1554
|
* Build an article-resources provider. `listPromoted` holds a memoized-promise
|
|
@@ -1795,7 +1794,7 @@ const formatTopology = (data) => {
|
|
|
1795
1794
|
"## Permission groups",
|
|
1796
1795
|
...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)._")
|
|
1797
1796
|
].join("\n");
|
|
1798
|
-
return truncateIfNeeded(text);
|
|
1797
|
+
return truncateIfNeeded(text, "This resource takes no parameters; walk the tree with list_categories and list_sections instead.");
|
|
1799
1798
|
};
|
|
1800
1799
|
/**
|
|
1801
1800
|
* Build a topology provider holding a memoized-promise cache (TTL
|
|
@@ -2026,6 +2025,10 @@ const renderGapVerdict = (report, gapCount, unclassified) => {
|
|
|
2026
2025
|
const allClear = `No gaps: all ${scanned.categories} category/ies and ${scanned.sections} section(s) scanned have a published "${locale}" translation.`;
|
|
2027
2026
|
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;
|
|
2028
2027
|
};
|
|
2028
|
+
const gapAdvice = (report) => {
|
|
2029
|
+
if (!report.categoryScoped) return "find_translation_gaps takes no pagination parameter; narrow the audit to one branch of the tree with category_id instead.";
|
|
2030
|
+
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.";
|
|
2031
|
+
};
|
|
2029
2032
|
const renderGapReport = (report) => {
|
|
2030
2033
|
const { locale, categoryGaps, sectionGaps, scanned, found } = report;
|
|
2031
2034
|
const gapCount = categoryGaps.length + sectionGaps.length;
|
|
@@ -2041,8 +2044,8 @@ const renderGapReport = (report) => {
|
|
|
2041
2044
|
"",
|
|
2042
2045
|
renderGapVerdict(report, gapCount, unclassified),
|
|
2043
2046
|
...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._`] : [],
|
|
2044
|
-
...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._`] : []
|
|
2045
|
-
].join("\n"));
|
|
2047
|
+
...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._`] : []
|
|
2048
|
+
].join("\n"), gapAdvice(report));
|
|
2046
2049
|
};
|
|
2047
2050
|
const largeArticleHint = (body, sectionCount) => {
|
|
2048
2051
|
if (body.length < 3e3 && sectionCount < 4) return null;
|
|
@@ -2204,7 +2207,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2204
2207
|
const text = (largeArticleHint(article.body, parseSections(article.body).length) ?? "") + formatArticle(article) + `\n\n**Available translations**: ${translations.map((t) => t.locale).join(", ")}`;
|
|
2205
2208
|
return { content: [{
|
|
2206
2209
|
type: "text",
|
|
2207
|
-
text: truncateIfNeeded(text)
|
|
2210
|
+
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.")
|
|
2208
2211
|
}] };
|
|
2209
2212
|
}
|
|
2210
2213
|
},
|
|
@@ -2339,7 +2342,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2339
2342
|
const note = scanCostNote(truncated, pagesScanned, cost);
|
|
2340
2343
|
return { content: [{
|
|
2341
2344
|
type: "text",
|
|
2342
|
-
text: truncateIfNeeded(`${header}\n\n${body}${note}
|
|
2345
|
+
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.")
|
|
2343
2346
|
}] };
|
|
2344
2347
|
}
|
|
2345
2348
|
},
|
|
@@ -2362,7 +2365,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2362
2365
|
const translations = await listTranslations(subdomain, token, article_id);
|
|
2363
2366
|
return { content: [{
|
|
2364
2367
|
type: "text",
|
|
2365
|
-
text: formatList(translations, formatTranslationSummary)
|
|
2368
|
+
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.")
|
|
2366
2369
|
}] };
|
|
2367
2370
|
}
|
|
2368
2371
|
},
|
|
@@ -2448,7 +2451,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2448
2451
|
const translations = await listNodeTranslations(subdomain, token, "sections", section_id);
|
|
2449
2452
|
return { content: [{
|
|
2450
2453
|
type: "text",
|
|
2451
|
-
text: formatList(translations, formatNodeTranslationSummary)
|
|
2454
|
+
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.")
|
|
2452
2455
|
}] };
|
|
2453
2456
|
}
|
|
2454
2457
|
},
|
|
@@ -2471,7 +2474,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2471
2474
|
const translations = await listNodeTranslations(subdomain, token, "categories", category_id);
|
|
2472
2475
|
return { content: [{
|
|
2473
2476
|
type: "text",
|
|
2474
|
-
text: formatList(translations, formatNodeTranslationSummary)
|
|
2477
|
+
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.")
|
|
2475
2478
|
}] };
|
|
2476
2479
|
}
|
|
2477
2480
|
},
|
|
@@ -2523,7 +2526,8 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2523
2526
|
categories: allCategories.length,
|
|
2524
2527
|
sections: allSections.length
|
|
2525
2528
|
},
|
|
2526
|
-
listingIncomplete: categoryScope.hasMore || extractPaginationMeta(sectionsRes, allSections.length).has_more
|
|
2529
|
+
listingIncomplete: categoryScope.hasMore || extractPaginationMeta(sectionsRes, allSections.length).has_more,
|
|
2530
|
+
categoryScoped: category_id !== void 0
|
|
2527
2531
|
})
|
|
2528
2532
|
}] };
|
|
2529
2533
|
}
|
|
@@ -2610,7 +2614,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2610
2614
|
}
|
|
2611
2615
|
return { content: [{
|
|
2612
2616
|
type: "text",
|
|
2613
|
-
text: formatList(response.permission_groups ?? [], formatPermissionGroup)
|
|
2617
|
+
text: formatList(response.permission_groups ?? [], formatPermissionGroup, void 0, "list_permission_groups takes no parameters, so this listing cannot be narrowed from the call.")
|
|
2614
2618
|
}] };
|
|
2615
2619
|
}
|
|
2616
2620
|
},
|
|
@@ -2848,7 +2852,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2848
2852
|
const response = await helpCenterGet(subdomain, token, "/articles/labels");
|
|
2849
2853
|
return { content: [{
|
|
2850
2854
|
type: "text",
|
|
2851
|
-
text: formatList(response.labels ?? [], formatLabel)
|
|
2855
|
+
text: formatList(response.labels ?? [], formatLabel, void 0, "list_labels takes no parameters, so this listing cannot be narrowed from the call.")
|
|
2852
2856
|
}] };
|
|
2853
2857
|
}
|
|
2854
2858
|
},
|
|
@@ -2876,7 +2880,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2876
2880
|
}
|
|
2877
2881
|
return { content: [{
|
|
2878
2882
|
type: "text",
|
|
2879
|
-
text: formatList(response.user_segments ?? [], formatUserSegment)
|
|
2883
|
+
text: formatList(response.user_segments ?? [], formatUserSegment, void 0, "list_user_segments takes no parameters, so this listing cannot be narrowed from the call.")
|
|
2880
2884
|
}] };
|
|
2881
2885
|
}
|
|
2882
2886
|
},
|
|
@@ -2903,7 +2907,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2903
2907
|
}] };
|
|
2904
2908
|
return { content: [{
|
|
2905
2909
|
type: "text",
|
|
2906
|
-
text: formatList(attachments, formatAttachment)
|
|
2910
|
+
text: formatList(attachments, formatAttachment, void 0, "list_article_attachments takes only article_id, so this listing cannot be narrowed from the call.")
|
|
2907
2911
|
}] };
|
|
2908
2912
|
}
|
|
2909
2913
|
},
|
|
@@ -2982,7 +2986,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2982
2986
|
].join("\n");
|
|
2983
2987
|
return { content: [{
|
|
2984
2988
|
type: "text",
|
|
2985
|
-
text: truncateIfNeeded(text)
|
|
2989
|
+
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.")
|
|
2986
2990
|
}] };
|
|
2987
2991
|
}
|
|
2988
2992
|
},
|
|
@@ -3186,6 +3190,16 @@ const fetchAllTicketComments = async (subdomain, token, ticketId) => {
|
|
|
3186
3190
|
}
|
|
3187
3191
|
return all;
|
|
3188
3192
|
};
|
|
3193
|
+
const commentPageMeta = (response, itemCount) => extractPaginationMeta({
|
|
3194
|
+
...response.meta && { meta: response.meta },
|
|
3195
|
+
...response.count !== void 0 && { count: response.count }
|
|
3196
|
+
}, itemCount);
|
|
3197
|
+
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." : "";
|
|
3198
|
+
const commentPageOrder = (comments, sortOrder) => {
|
|
3199
|
+
const first = comments[0]?.created_at ?? "";
|
|
3200
|
+
const last = comments.at(-1)?.created_at ?? "";
|
|
3201
|
+
return (first === last ? sortOrder === "desc" : first > last) ? "newest first" : "oldest first";
|
|
3202
|
+
};
|
|
3189
3203
|
const fetchAttachmentsByIds = async (subdomain, token, ids) => {
|
|
3190
3204
|
const attachments = [];
|
|
3191
3205
|
for (const id of ids) try {
|
|
@@ -3328,21 +3342,29 @@ const collectAuditIds = (audits) => {
|
|
|
3328
3342
|
groupIds: [...groupIds]
|
|
3329
3343
|
};
|
|
3330
3344
|
};
|
|
3345
|
+
const resolveEntityNames = async (subdomain, token, path, key, ids) => {
|
|
3346
|
+
const map = /* @__PURE__ */ new Map();
|
|
3347
|
+
for (const batch of chunk(ids, 100)) try {
|
|
3348
|
+
const res = await zendeskGet(subdomain, token, path, { ids: batch.join(",") });
|
|
3349
|
+
for (const entity of res[key] ?? []) map.set(entity.id, entity.name);
|
|
3350
|
+
} catch {}
|
|
3351
|
+
return map;
|
|
3352
|
+
};
|
|
3353
|
+
const resolveUserNames = (subdomain, token, ids) => resolveEntityNames(subdomain, token, "/users/show_many", "users", ids);
|
|
3331
3354
|
const resolveAuditNames = async (subdomain, token, userIds, groupIds) => {
|
|
3332
|
-
const
|
|
3333
|
-
const map = /* @__PURE__ */ new Map();
|
|
3334
|
-
for (const batch of chunk(ids, 100)) try {
|
|
3335
|
-
const res = await zendeskGet(subdomain, token, path, { ids: batch.join(",") });
|
|
3336
|
-
for (const entity of res[key] ?? []) map.set(entity.id, entity.name);
|
|
3337
|
-
} catch {}
|
|
3338
|
-
return map;
|
|
3339
|
-
};
|
|
3340
|
-
const [users, groups] = await Promise.all([resolve("/users/show_many", "users", userIds), resolve("/groups/show_many", "groups", groupIds)]);
|
|
3355
|
+
const [users, groups] = await Promise.all([resolveUserNames(subdomain, token, userIds), resolveEntityNames(subdomain, token, "/groups/show_many", "groups", groupIds)]);
|
|
3341
3356
|
return {
|
|
3342
3357
|
users,
|
|
3343
3358
|
groups
|
|
3344
3359
|
};
|
|
3345
3360
|
};
|
|
3361
|
+
const resolveCommentAuthors = async (subdomain, token, comments, sideloaded = []) => {
|
|
3362
|
+
const authors = new Map(sideloaded.map((user) => [user.id, user.name]));
|
|
3363
|
+
const missing = [...new Set(comments.map((comment) => comment.author_id))].filter((id) => id > 0 && !authors.has(id));
|
|
3364
|
+
if (missing.length === 0) return authors;
|
|
3365
|
+
for (const [id, name] of await resolveUserNames(subdomain, token, missing)) authors.set(id, name);
|
|
3366
|
+
return authors;
|
|
3367
|
+
};
|
|
3346
3368
|
const DIFF_SKIP_KEYS = /* @__PURE__ */ new Set([
|
|
3347
3369
|
"comment",
|
|
3348
3370
|
"fields",
|
|
@@ -3432,10 +3454,10 @@ const createTicketTools = (ctx) => {
|
|
|
3432
3454
|
namespace: "tickets",
|
|
3433
3455
|
readOnly: true,
|
|
3434
3456
|
title: "Get Zendesk Ticket",
|
|
3435
|
-
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.",
|
|
3457
|
+
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.",
|
|
3436
3458
|
inputSchema: z.object({
|
|
3437
3459
|
ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket to fetch. Obtain it from search_tickets or list_tickets."),
|
|
3438
|
-
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.")
|
|
3460
|
+
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.")
|
|
3439
3461
|
}),
|
|
3440
3462
|
annotations: {
|
|
3441
3463
|
readOnlyHint: true,
|
|
@@ -3449,12 +3471,17 @@ const createTicketTools = (ctx) => {
|
|
|
3449
3471
|
const { ticket } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}`);
|
|
3450
3472
|
let text = formatTicket(ticket) + formatSlaBlock(await fetchTicketSla(subdomain, token, ticket));
|
|
3451
3473
|
if (include_comments) {
|
|
3452
|
-
const { comments } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments`, {
|
|
3453
|
-
|
|
3474
|
+
const { comments, users } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments`, {
|
|
3475
|
+
include: "users",
|
|
3476
|
+
include_inline_images: "true"
|
|
3477
|
+
});
|
|
3478
|
+
const authors = await resolveCommentAuthors(subdomain, token, comments ?? [], users);
|
|
3479
|
+
text += `\n\n---\n# Comments\n\n${(comments ?? []).map((comment) => formatComment(comment, authors)).join("\n\n")}`;
|
|
3454
3480
|
}
|
|
3481
|
+
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.";
|
|
3455
3482
|
return { content: [{
|
|
3456
3483
|
type: "text",
|
|
3457
|
-
text: truncateIfNeeded(text)
|
|
3484
|
+
text: truncateIfNeeded(text, advice)
|
|
3458
3485
|
}] };
|
|
3459
3486
|
}
|
|
3460
3487
|
},
|
|
@@ -3463,7 +3490,7 @@ const createTicketTools = (ctx) => {
|
|
|
3463
3490
|
namespace: "tickets",
|
|
3464
3491
|
readOnly: true,
|
|
3465
3492
|
title: "Get Zendesk Ticket History",
|
|
3466
|
-
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.",
|
|
3493
|
+
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.",
|
|
3467
3494
|
inputSchema: z.object({
|
|
3468
3495
|
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."),
|
|
3469
3496
|
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."),
|
|
@@ -3503,6 +3530,53 @@ const createTicketTools = (ctx) => {
|
|
|
3503
3530
|
}] };
|
|
3504
3531
|
}
|
|
3505
3532
|
},
|
|
3533
|
+
{
|
|
3534
|
+
name: "list_ticket_comments",
|
|
3535
|
+
namespace: "tickets",
|
|
3536
|
+
readOnly: true,
|
|
3537
|
+
title: "List Zendesk Ticket Comments",
|
|
3538
|
+
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.",
|
|
3539
|
+
inputSchema: z.object({
|
|
3540
|
+
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."),
|
|
3541
|
+
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\"."),
|
|
3542
|
+
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."),
|
|
3543
|
+
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.")
|
|
3544
|
+
}),
|
|
3545
|
+
annotations: {
|
|
3546
|
+
readOnlyHint: true,
|
|
3547
|
+
destructiveHint: false,
|
|
3548
|
+
idempotentHint: true,
|
|
3549
|
+
openWorldHint: true
|
|
3550
|
+
},
|
|
3551
|
+
handler: async (params) => {
|
|
3552
|
+
const { ticket_id, sort_order, page_size, cursor } = params;
|
|
3553
|
+
const token = await getToken();
|
|
3554
|
+
const response = await zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments`, {
|
|
3555
|
+
...buildCursorParams(page_size, cursor),
|
|
3556
|
+
sort: sort_order === "desc" ? "-created_at" : "created_at",
|
|
3557
|
+
include: "users",
|
|
3558
|
+
include_inline_images: "true"
|
|
3559
|
+
});
|
|
3560
|
+
const comments = response.comments ?? [];
|
|
3561
|
+
const meta = commentPageMeta(response, comments.length);
|
|
3562
|
+
const offsetNote = offsetPageNote(response);
|
|
3563
|
+
if (comments.length === 0) return { content: [{
|
|
3564
|
+
type: "text",
|
|
3565
|
+
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}`
|
|
3566
|
+
}] };
|
|
3567
|
+
const authors = await resolveCommentAuthors(subdomain, token, comments, response.users);
|
|
3568
|
+
const body = comments.map((comment) => formatComment(comment, authors)).join("\n\n");
|
|
3569
|
+
const text = `${[
|
|
3570
|
+
`# Comments on ticket #${ticket_id} (${commentPageOrder(comments, sort_order)})`,
|
|
3571
|
+
formatPagination(meta),
|
|
3572
|
+
body
|
|
3573
|
+
].join("\n\n")}${offsetNote}`;
|
|
3574
|
+
return { content: [{
|
|
3575
|
+
type: "text",
|
|
3576
|
+
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.")
|
|
3577
|
+
}] };
|
|
3578
|
+
}
|
|
3579
|
+
},
|
|
3506
3580
|
{
|
|
3507
3581
|
name: "get_ticket_attachments",
|
|
3508
3582
|
namespace: "tickets",
|
|
@@ -3511,7 +3585,7 @@ const createTicketTools = (ctx) => {
|
|
|
3511
3585
|
description: "Retrieve ticket attachments. Images are embedded inline; other files are listed as text references.",
|
|
3512
3586
|
inputSchema: z.object({
|
|
3513
3587
|
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."),
|
|
3514
|
-
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.")
|
|
3588
|
+
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.")
|
|
3515
3589
|
}),
|
|
3516
3590
|
annotations: {
|
|
3517
3591
|
readOnlyHint: true,
|
|
@@ -3779,7 +3853,7 @@ const createTicketTools = (ctx) => {
|
|
|
3779
3853
|
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}.`;
|
|
3780
3854
|
return { content: [{
|
|
3781
3855
|
type: "text",
|
|
3782
|
-
text: truncateIfNeeded(text)
|
|
3856
|
+
text: truncateIfNeeded(text, "get_linked_incidents takes no pagination or filter parameters, so this response cannot be narrowed from the call.")
|
|
3783
3857
|
}] };
|
|
3784
3858
|
}
|
|
3785
3859
|
},
|
|
@@ -4011,7 +4085,7 @@ const createTicketTools = (ctx) => {
|
|
|
4011
4085
|
const [{ ticket: before }, { result }] = await Promise.all([zendeskGet(subdomain, token, `/tickets/${ticket_id}`), zendeskGet(subdomain, token, `/tickets/${ticket_id}/macros/${macro_id}/apply`)]);
|
|
4012
4086
|
return { content: [{
|
|
4013
4087
|
type: "text",
|
|
4014
|
-
text: truncateIfNeeded(formatMacroPreviewDiff(ticket_id, macro_id, before, result))
|
|
4088
|
+
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.")
|
|
4015
4089
|
}] };
|
|
4016
4090
|
}
|
|
4017
4091
|
}
|
|
@@ -4544,6 +4618,10 @@ const registerReloadTool = (server, reload, logger = silentLogger) => {
|
|
|
4544
4618
|
* `reload_tools` tool that hot-reloads edited tool code on demand. stdio only —
|
|
4545
4619
|
* HTTP builds a per-session server per request, so there is no long-lived
|
|
4546
4620
|
* server to hot-swap.
|
|
4621
|
+
*
|
|
4622
|
+
* Returns the running server so the caller can close it on shutdown: dev mode
|
|
4623
|
+
* runs over the same stdio transport as normal mode and must exit the same way
|
|
4624
|
+
* when the client disconnects.
|
|
4547
4625
|
*/
|
|
4548
4626
|
/* v8 ignore start -- runtime bootstrap: binds the reload tool to a real stdio
|
|
4549
4627
|
transport; the reload machinery it wires up is covered by dev-reload.test.ts */
|
|
@@ -4552,6 +4630,7 @@ const startDevServer = async (config, getToken, logger = silentLogger, onUnautho
|
|
|
4552
4630
|
registerReloadTool(server, reload, logger);
|
|
4553
4631
|
await startStdioTransport(server, logger);
|
|
4554
4632
|
logger.info("dev_mode_enabled");
|
|
4633
|
+
return server;
|
|
4555
4634
|
};
|
|
4556
4635
|
/* v8 ignore stop */
|
|
4557
4636
|
//#endregion
|
|
@@ -4907,27 +4986,125 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
4907
4986
|
};
|
|
4908
4987
|
};
|
|
4909
4988
|
//#endregion
|
|
4989
|
+
//#region src/utils/shutdown.ts
|
|
4990
|
+
/**
|
|
4991
|
+
* How long a shutdown may take before the watchdog forces the exit.
|
|
4992
|
+
*
|
|
4993
|
+
* Generous enough for `server.close()` and an HTTP session drain, short enough
|
|
4994
|
+
* to stay inside the tightest common supervisor grace — `docker stop` allows 10s
|
|
4995
|
+
* and Kubernetes 30s before their own SIGKILL (systemd is far laxer at 90s). A
|
|
4996
|
+
* process killed by its supervisor is exactly the unclean exit this removes.
|
|
4997
|
+
*/
|
|
4998
|
+
const SHUTDOWN_GRACE_MS = 3e3;
|
|
4999
|
+
/**
|
|
5000
|
+
* Adapt a `process` to a {@link ShutdownRuntime}. Takes the process as an
|
|
5001
|
+
* argument rather than closing over the global so the adapter itself is
|
|
5002
|
+
* testable — otherwise the one part of this module that touches the real
|
|
5003
|
+
* process would be the one part no test can reach.
|
|
5004
|
+
*/
|
|
5005
|
+
const createRuntime = (proc) => ({
|
|
5006
|
+
on: (event, listener) => {
|
|
5007
|
+
proc.on(event, listener);
|
|
5008
|
+
},
|
|
5009
|
+
stdin: { on: (event, listener) => {
|
|
5010
|
+
proc.stdin.on(event, listener);
|
|
5011
|
+
} },
|
|
5012
|
+
exit: (code) => {
|
|
5013
|
+
proc.exit(code);
|
|
5014
|
+
},
|
|
5015
|
+
setTimer: (fn, ms) => {
|
|
5016
|
+
const timer = setTimeout(fn, ms);
|
|
5017
|
+
return {
|
|
5018
|
+
unref: () => void timer.unref(),
|
|
5019
|
+
clear: () => clearTimeout(timer)
|
|
5020
|
+
};
|
|
5021
|
+
}
|
|
5022
|
+
});
|
|
5023
|
+
const defaultRuntime = createRuntime(process);
|
|
5024
|
+
/**
|
|
5025
|
+
* Install the process's one shutdown path and return its trigger.
|
|
5026
|
+
*
|
|
5027
|
+
* Registering a `SIGTERM` handler *removes* Node's default terminate, which
|
|
5028
|
+
* makes the exit our responsibility: a cleanup that stalls on an in-flight
|
|
5029
|
+
* request would otherwise leave a process SIGTERM cannot kill — the very
|
|
5030
|
+
* symptom this exists to remove. Hence the watchdog, which is load-bearing
|
|
5031
|
+
* rather than defensive, and the unconditional `exit` on every path.
|
|
5032
|
+
*
|
|
5033
|
+
* The exit is explicit rather than a drained event loop because the OAuth
|
|
5034
|
+
* callback server (`auth/browser-oauth.ts`) is a listening socket that is not
|
|
5035
|
+
* `unref()`'d: letting the loop drain would keep a disconnected session alive
|
|
5036
|
+
* for up to the 5-minute auth timeout.
|
|
5037
|
+
*/
|
|
5038
|
+
const installShutdown = (options) => {
|
|
5039
|
+
const { cleanup, logger, watchStdin, graceMs = SHUTDOWN_GRACE_MS } = options;
|
|
5040
|
+
const runtime = options.runtime ?? defaultRuntime;
|
|
5041
|
+
let started = false;
|
|
5042
|
+
let exited = false;
|
|
5043
|
+
const exitOnce = (code) => {
|
|
5044
|
+
if (exited) return;
|
|
5045
|
+
exited = true;
|
|
5046
|
+
runtime.exit(code);
|
|
5047
|
+
};
|
|
5048
|
+
const shutdown = async (reason) => {
|
|
5049
|
+
if (started) return;
|
|
5050
|
+
started = true;
|
|
5051
|
+
logger.info("shutdown_started", { reason });
|
|
5052
|
+
const watchdog = runtime.setTimer(() => {
|
|
5053
|
+
logger.warn("shutdown_forced", { graceMs });
|
|
5054
|
+
exitOnce(0);
|
|
5055
|
+
}, graceMs);
|
|
5056
|
+
watchdog.unref();
|
|
5057
|
+
try {
|
|
5058
|
+
await cleanup();
|
|
5059
|
+
logger.info("shutdown_complete", { reason });
|
|
5060
|
+
} catch (err) {
|
|
5061
|
+
logger.warn("shutdown_cleanup_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
5062
|
+
} finally {
|
|
5063
|
+
watchdog.clear();
|
|
5064
|
+
exitOnce(0);
|
|
5065
|
+
}
|
|
5066
|
+
};
|
|
5067
|
+
runtime.on("SIGINT", () => void shutdown("SIGINT"));
|
|
5068
|
+
runtime.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
5069
|
+
if (watchStdin) runtime.stdin.on("end", () => void shutdown("stdin_eof"));
|
|
5070
|
+
return shutdown;
|
|
5071
|
+
};
|
|
5072
|
+
//#endregion
|
|
4910
5073
|
//#region src/index.ts
|
|
4911
5074
|
const buildStdioTokenStore = (config, logger) => createTokenStore({
|
|
4912
5075
|
subdomain: config.subdomain,
|
|
4913
5076
|
oauthClientId: config.oauthClientId,
|
|
4914
5077
|
callbackPort: config.callbackPort
|
|
4915
5078
|
}, logger);
|
|
5079
|
+
const connectStdio = async (config, tokenStore, logger) => {
|
|
5080
|
+
if (config.dev) return startDevServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
|
|
5081
|
+
const server = createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
|
|
5082
|
+
await startStdioTransport(server, logger);
|
|
5083
|
+
return server;
|
|
5084
|
+
};
|
|
4916
5085
|
const main = async () => {
|
|
4917
5086
|
const config = loadConfig();
|
|
4918
5087
|
const logger = createLogger(config.logLevel);
|
|
4919
5088
|
if (config.transport === "stdio") {
|
|
4920
5089
|
const tokenStore = buildStdioTokenStore(config, logger);
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
5090
|
+
const server = await connectStdio(config, tokenStore, logger);
|
|
5091
|
+
installShutdown({
|
|
5092
|
+
watchStdin: true,
|
|
5093
|
+
logger,
|
|
5094
|
+
cleanup: async () => {
|
|
5095
|
+
await server.close();
|
|
5096
|
+
tokenStore.dispose();
|
|
5097
|
+
}
|
|
5098
|
+
});
|
|
4927
5099
|
return;
|
|
4928
5100
|
}
|
|
4929
5101
|
if (config.dev) logger.warn("dev_mode_ignored_http");
|
|
4930
|
-
await startHttpTransport(config, logger);
|
|
5102
|
+
const http = await startHttpTransport(config, logger);
|
|
5103
|
+
installShutdown({
|
|
5104
|
+
watchStdin: false,
|
|
5105
|
+
logger,
|
|
5106
|
+
cleanup: http.close
|
|
5107
|
+
});
|
|
4931
5108
|
};
|
|
4932
5109
|
main().catch((error) => {
|
|
4933
5110
|
console.error("Fatal error:", error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fruggr/zendesk-mcp-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.20.0",
|
|
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,13 +69,13 @@
|
|
|
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",
|
|
76
76
|
"hast-util-to-html": "9.0.5",
|
|
77
77
|
"hast-util-to-mdast": "10.1.2",
|
|
78
|
-
"open": "11.0.
|
|
78
|
+
"open": "11.0.1",
|
|
79
79
|
"rehype-parse": "9.0.1",
|
|
80
80
|
"rehype-raw": "7.0.0",
|
|
81
81
|
"rehype-remark": "10.0.1",
|
|
@@ -88,15 +88,15 @@
|
|
|
88
88
|
"zod": "4.4.3"
|
|
89
89
|
},
|
|
90
90
|
"devDependencies": {
|
|
91
|
-
"@biomejs/biome": "2.5.
|
|
91
|
+
"@biomejs/biome": "2.5.11",
|
|
92
92
|
"@semantic-release/changelog": "^7.0.0",
|
|
93
93
|
"@semantic-release/exec": "^7.1.0",
|
|
94
94
|
"@semantic-release/git": "^11.0.0",
|
|
95
95
|
"@semantic-release/github": "^12.0.6",
|
|
96
96
|
"@semantic-release/npm": "^13.1.5",
|
|
97
97
|
"@semantic-release/release-notes-generator": "^14.1.1",
|
|
98
|
-
"@stryker-mutator/core": "^
|
|
99
|
-
"@stryker-mutator/vitest-runner": "^
|
|
98
|
+
"@stryker-mutator/core": "^10.0.0",
|
|
99
|
+
"@stryker-mutator/vitest-runner": "^10.0.0",
|
|
100
100
|
"@tsconfig/node20": "^20.1.9",
|
|
101
101
|
"@tsconfig/strictest": "^2.0.8",
|
|
102
102
|
"@types/hast": "^3.0.4",
|