@fruggr/zendesk-mcp-server 2.17.1 → 2.18.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.
Files changed (2) hide show
  1. package/dist/index.js +229 -46
  2. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -126,7 +126,6 @@ const positiveIntEnv = (name, fallback) => {
126
126
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
127
127
  };
128
128
  const ARTICLE_RESOURCES_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES", 20);
129
- const TRANSLATION_GAP_SCAN_MAX_NODES = positiveIntEnv("ZENDESK_TRANSLATION_GAP_SCAN_MAX_NODES", 60);
130
129
  const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5242880);
131
130
  const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("ZENDESK_MAX_EMBEDDED_IMAGES", 10);
132
131
  const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
@@ -813,26 +812,195 @@ const loadConfig = (argv = process.argv.slice(2)) => {
813
812
  });
814
813
  };
815
814
  //#endregion
815
+ //#region src/client/retry.ts
816
+ const MAX_ATTEMPTS = 3;
817
+ const BASE_DELAY_MS = 250;
818
+ const MAX_DELAY_MS = 4e3;
819
+ const REQUEST_TIMEOUT_MS = 3e4;
820
+ const TRANSFER_TIMEOUT_MS = 12e4;
821
+ /**
822
+ * A deadline for one attempt. The abort surfaces as a `TimeoutError` whose `code`
823
+ * is numeric, not a syscall string, so it classifies as `unknown`: a GET is
824
+ * retried, a write is not — the request may have arrived before the deadline.
825
+ */
826
+ const deadlineSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs);
827
+ const MAX_RETRY_AFTER_MS = 5e3;
828
+ const POLICIES = {
829
+ GET: {
830
+ network: "any",
831
+ serverErrors: true
832
+ },
833
+ DELETE: {
834
+ network: "pre-send",
835
+ serverErrors: false
836
+ },
837
+ POST: {
838
+ network: "pre-send",
839
+ serverErrors: false
840
+ },
841
+ PUT: {
842
+ network: "pre-send",
843
+ serverErrors: false
844
+ }
845
+ };
846
+ const DELAY_SECONDS = /^\d+$/;
847
+ const HTTP_DATE_START = /^[A-Za-z]/;
848
+ const NON_ASCII = /[^ -~]/g;
849
+ const TOKEN_SEGMENT = /\/token\/[^/]+/;
850
+ const URL_IN_TEXT = /[a-z][a-z0-9+.-]*:\/\/\S+/gi;
851
+ const PRE_SEND_CODES = /* @__PURE__ */ new Set([
852
+ "ENOTFOUND",
853
+ "EAI_AGAIN",
854
+ "ECONNREFUSED",
855
+ "UND_ERR_CONNECT_TIMEOUT"
856
+ ]);
857
+ const defaultRetryDeps = {
858
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
859
+ random: Math.random
860
+ };
861
+ /** Non-ASCII bytes break `node:http` headers, so client error text stays ASCII. */
862
+ const toAscii = (text) => text.replace(NON_ASCII, "?");
863
+ /**
864
+ * Identifies the request without leaking a credential: uploads carry an upload
865
+ * token in the query string, and an attachment `content_url` carries a download
866
+ * token as a path segment (`/attachments/token/<token>/`).
867
+ */
868
+ const describeTarget = (url) => {
869
+ const { origin, pathname } = new URL(url);
870
+ return `${origin}${pathname.replace(TOKEN_SEGMENT, "/token/[redacted]")}`;
871
+ };
872
+ /** First `code` in the cause chain, inspecting 5 levels so a cycle cannot hang. */
873
+ const errorCode = (err) => {
874
+ let current = err;
875
+ for (let depth = 0; depth < 5 && current !== null && typeof current === "object"; depth += 1) {
876
+ const { code, cause } = current;
877
+ if (typeof code === "string") return code;
878
+ current = cause;
879
+ }
880
+ };
881
+ const classifyNetworkError = (err) => PRE_SEND_CODES.has(errorCode(err)) ? "pre-send" : "unknown";
882
+ const UNINFORMATIVE_NAMES = /* @__PURE__ */ new Set(["Error", "TypeError"]);
883
+ /**
884
+ * The client refuses to replay a write that may have landed — but the caller is
885
+ * an LLM whose reflex on a failed tool call is to try again, which would undo
886
+ * that. So a failed write says whether it may have taken effect. ASCII only,
887
+ * same rule as the rest of the message.
888
+ */
889
+ const MAY_HAVE_APPLIED_NOTE = "The write may already have been applied. Check the current state before retrying, or you may duplicate it.";
890
+ const NEVER_SENT_NOTE = "The request never reached Zendesk, so nothing was applied. Retrying is safe.";
891
+ const REFUSED_NOTE = "Zendesk refused the request, so nothing was applied. Retrying is safe.";
892
+ /** Only a write can be duplicated by a replay, so only a write carries a note. */
893
+ const writeNote = (method, note) => method === "GET" ? "" : ` ${note}`;
894
+ /**
895
+ * A cause message can quote the URL it failed on — Node's `fetch` refuses a URL
896
+ * carrying credentials by printing the whole thing, query string included — which
897
+ * would smuggle back exactly what `describeTarget` drops. Same treatment for both,
898
+ * so one function owns what a URL may look like in our output.
899
+ */
900
+ const redactUrls = (text) => text.replace(URL_IN_TEXT, (found) => {
901
+ try {
902
+ return new URL(found).origin === "null" ? "[url]" : describeTarget(found);
903
+ } catch {
904
+ return "[url]";
905
+ }
906
+ });
907
+ const failureDetail = (cause) => {
908
+ if (!(cause instanceof Error)) return redactUrls(String(cause));
909
+ const message = redactUrls(cause.message);
910
+ const code = errorCode(cause);
911
+ if (code !== void 0) return `${code}: ${message}`;
912
+ return UNINFORMATIVE_NAMES.has(cause.name) ? message : `${cause.name}: ${message}`;
913
+ };
914
+ const networkErrorMessage = (method, target, attempts, cause) => {
915
+ const base = `Network error on ${method} ${target} after ${attempts === 1 ? "1 attempt" : `${attempts} attempts`}: ${failureDetail(cause)}`;
916
+ const note = classifyNetworkError(cause) === "pre-send" ? NEVER_SENT_NOTE : MAY_HAVE_APPLIED_NOTE;
917
+ return toAscii(method === "GET" ? base : `${base}.${writeNote(method, note)}`);
918
+ };
919
+ const createZendeskNetworkError = (method, target, attempts, cause) => Object.assign(new Error(networkErrorMessage(method, target, attempts, cause), { cause }), {
920
+ name: "ZendeskNetworkError",
921
+ method,
922
+ target,
923
+ attempts
924
+ });
925
+ /** Exponential backoff with equal jitter: half the window fixed, half random. */
926
+ const computeBackoffMs = (attempt, random) => {
927
+ const window = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS);
928
+ return Math.round(window / 2 + window * random() / 2);
929
+ };
930
+ /** `Retry-After` in ms — delay-seconds or HTTP-date. */
931
+ const parseRetryAfter = (header, now = Date.now()) => {
932
+ if (header === null) return void 0;
933
+ const value = header.trim();
934
+ if (DELAY_SECONDS.test(value)) return Number(value) * 1e3;
935
+ if (!HTTP_DATE_START.test(value)) return void 0;
936
+ const date = Date.parse(value);
937
+ if (Number.isNaN(date)) return void 0;
938
+ return Math.max(0, date - now);
939
+ };
940
+ /**
941
+ * How long to wait before replaying this response, or undefined to accept it.
942
+ * `Retry-After` wins over backoff wherever it appears — Zendesk sends it on a 503
943
+ * during maintenance as well as on a 429 — and a value past the cap means the
944
+ * response is surfaced rather than parking the call for that long.
945
+ */
946
+ const retryDelayFor = (response, policy, attempt, random) => {
947
+ if (!(response.status === 429 || response.status >= 500 && policy.serverErrors)) return void 0;
948
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
949
+ if (retryAfter === void 0) return computeBackoffMs(attempt, random);
950
+ return retryAfter > MAX_RETRY_AFTER_MS ? void 0 : retryAfter;
951
+ };
952
+ /**
953
+ * Runs `attempt` until it succeeds, hits a terminal outcome, or spends the
954
+ * attempt budget. Returns the last response for the caller to inspect (a
955
+ * non-ok status is still the caller's to turn into a `ZendeskApiError`), and
956
+ * throws `ZendeskNetworkError` when no response was ever obtained.
957
+ */
958
+ const fetchWithRetry = async (attempt, method, target, deps = defaultRetryDeps) => {
959
+ const policy = POLICIES[method];
960
+ for (let tries = 1;; tries += 1) {
961
+ const last = tries >= MAX_ATTEMPTS;
962
+ let response;
963
+ try {
964
+ response = await attempt();
965
+ } catch (err) {
966
+ const replayable = policy.network === "any" || classifyNetworkError(err) === policy.network;
967
+ if (last || !replayable) throw createZendeskNetworkError(method, target, tries, err);
968
+ await deps.sleep(computeBackoffMs(tries, deps.random));
969
+ continue;
970
+ }
971
+ if (last) return response;
972
+ const delayMs = retryDelayFor(response, policy, tries, deps.random);
973
+ if (delayMs === void 0) return response;
974
+ await response.text().catch(() => void 0);
975
+ await deps.sleep(delayMs);
976
+ }
977
+ };
978
+ //#endregion
816
979
  //#region src/client/zendesk-api.ts
817
980
  var ZendeskApiError = class ZendeskApiError extends Error {
818
981
  status;
819
982
  statusText;
820
983
  body;
821
- constructor(status, statusText, body) {
822
- super(ZendeskApiError.buildMessage(status, statusText, body));
984
+ method;
985
+ constructor(status, statusText, body, method) {
986
+ super(ZendeskApiError.buildMessage(status, statusText, body, method));
823
987
  this.status = status;
824
988
  this.statusText = statusText;
825
989
  this.body = body;
990
+ this.method = method;
826
991
  this.name = "ZendeskApiError";
827
992
  }
828
- static buildMessage(status, statusText, body) {
993
+ static buildMessage(status, statusText, body, method) {
829
994
  switch (status) {
830
995
  case 401: return "Authentication failed. Your Zendesk token may be expired or invalid. Re-authenticate to get a new token.";
831
996
  case 403: return "Permission denied. Your Zendesk account does not have access to this resource.";
832
997
  case 404: return `Resource not found. Please verify the ID is correct. (${statusText})`;
833
998
  case 422: return `Validation error: ${body}`;
834
- case 429: return "Rate limit exceeded. Please wait before making more requests.";
835
- default: return `Zendesk API error ${status}: ${statusText}. ${body}`;
999
+ case 429: return `Rate limit exceeded. Please wait before making more requests.${writeNote(method, REFUSED_NOTE)}`;
1000
+ default: {
1001
+ const message = `Zendesk API error ${status}: ${statusText}. ${body}`;
1002
+ return status >= 500 ? `${message}${writeNote(method, MAY_HAVE_APPLIED_NOTE)}` : message;
1003
+ }
836
1004
  }
837
1005
  }
838
1006
  };
@@ -842,6 +1010,11 @@ const buildUrl = (base, path, params) => {
842
1010
  if (params) for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
843
1011
  return url.toString();
844
1012
  };
1013
+ const performFetch = (method, url, init, timeoutMs = REQUEST_TIMEOUT_MS) => fetchWithRetry(() => fetch(url, {
1014
+ ...init,
1015
+ method,
1016
+ signal: deadlineSignal(timeoutMs)
1017
+ }), method, describeTarget(url));
845
1018
  const executeRequest = async (url, token, options = {}) => {
846
1019
  const { method = "GET", body } = options;
847
1020
  const headers = {
@@ -849,15 +1022,12 @@ const executeRequest = async (url, token, options = {}) => {
849
1022
  Accept: "application/json"
850
1023
  };
851
1024
  if (body) headers["Content-Type"] = "application/json";
852
- const init = {
853
- method,
854
- headers
855
- };
1025
+ const init = { headers };
856
1026
  if (body) init.body = JSON.stringify(body);
857
- const response = await fetch(url, init);
1027
+ const response = await performFetch(method, url, init);
858
1028
  if (!response.ok) {
859
1029
  const responseBody = await response.text();
860
- throw new ZendeskApiError(response.status, response.statusText, responseBody);
1030
+ throw new ZendeskApiError(response.status, response.statusText, responseBody, method);
861
1031
  }
862
1032
  if (response.status === 204) return {};
863
1033
  return response.json();
@@ -906,10 +1076,10 @@ const fetchZendeskBinary = async (subdomain, token, contentUrl) => {
906
1076
  const expectedHost = `${subdomain}.zendesk.com`;
907
1077
  const headers = {};
908
1078
  if (new URL(contentUrl).hostname === expectedHost) headers["Authorization"] = buildAuthHeader(token);
909
- const response = await fetch(contentUrl, { headers });
1079
+ const response = await performFetch("GET", contentUrl, { headers }, TRANSFER_TIMEOUT_MS);
910
1080
  if (!response.ok) {
911
1081
  const body = await response.text();
912
- throw new ZendeskApiError(response.status, response.statusText, body);
1082
+ throw new ZendeskApiError(response.status, response.statusText, body, "GET");
913
1083
  }
914
1084
  const contentType = response.headers.get("content-type") ?? "application/octet-stream";
915
1085
  const arrayBuffer = await response.arrayBuffer();
@@ -922,30 +1092,28 @@ const zendeskUpload = async (subdomain, token, filename, data, contentType, uplo
922
1092
  const params = { filename };
923
1093
  if (uploadToken) params["token"] = uploadToken;
924
1094
  const url = buildUrl(getBaseUrl(subdomain), "/uploads", params);
925
- const response = await fetch(url, {
926
- method: "POST",
1095
+ const response = await performFetch("POST", url, {
927
1096
  headers: {
928
1097
  Authorization: buildAuthHeader(token),
929
1098
  "Content-Type": contentType
930
1099
  },
931
1100
  body: data
932
- });
1101
+ }, TRANSFER_TIMEOUT_MS);
933
1102
  if (!response.ok) {
934
1103
  const responseBody = await response.text();
935
- throw new ZendeskApiError(response.status, response.statusText, responseBody);
1104
+ throw new ZendeskApiError(response.status, response.statusText, responseBody, "POST");
936
1105
  }
937
1106
  return response.json();
938
1107
  };
939
1108
  const helpCenterUpload = async (subdomain, token, path, formData) => {
940
1109
  const url = buildUrl(getHelpCenterBaseUrl(subdomain), path);
941
- const response = await fetch(url, {
942
- method: "POST",
1110
+ const response = await performFetch("POST", url, {
943
1111
  headers: { Authorization: buildAuthHeader(token) },
944
1112
  body: formData
945
- });
1113
+ }, TRANSFER_TIMEOUT_MS);
946
1114
  if (!response.ok) {
947
1115
  const responseBody = await response.text();
948
- throw new ZendeskApiError(response.status, response.statusText, responseBody);
1116
+ throw new ZendeskApiError(response.status, response.statusText, responseBody, "POST");
949
1117
  }
950
1118
  return response.json();
951
1119
  };
@@ -1802,6 +1970,7 @@ const nodeTranslationWriteText = (kind, nodeId, translation, created) => [
1802
1970
  "",
1803
1971
  formatNodeTranslationSummary(translation)
1804
1972
  ].join("\n");
1973
+ const TRANSLATIONS_SIDELOAD = "translations";
1805
1974
  const GAP_REASON_TEXT = {
1806
1975
  missing: "no translation",
1807
1976
  draft: "draft translation (not published)"
@@ -1819,40 +1988,49 @@ const classifyGap = (node, translations, locale) => {
1819
1988
  reason: "draft"
1820
1989
  } : null;
1821
1990
  };
1991
+ /**
1992
+ * Split a listing into the nodes that can be classified and the rest. A node
1993
+ * whose `translations` key is absent was answered without the sideload, and
1994
+ * reading that as "no translation" would report an entire Help Center as one big
1995
+ * gap — so it counts as unclassified and the report says how many.
1996
+ */
1997
+ const withSideloadedTranslations = (nodes) => nodes.filter((node) => Array.isArray(node.translations));
1822
1998
  const renderGapLines = (heading, gaps, scanned, found) => {
1823
1999
  const header = `## ${heading} (${scanned} scanned)`;
1824
2000
  if (gaps.length > 0) return [header, ...gaps.map((gap) => `- **${gap.name}** (${gap.id}) — ${GAP_REASON_TEXT[gap.reason]}`)];
1825
- if (scanned === 0) return [header, found === 0 ? "_(none to scan at this level)_" : `_(none scanned — the ${TRANSLATION_GAP_SCAN_MAX_NODES}-node cap was spent before this level; ${found} left unchecked, see the note below)_`];
2001
+ if (scanned === 0) return [header, found === 0 ? "_(none to scan at this level)_" : `_(none classified — the listing answered without the sideload for all ${found}, see the note below)_`];
1826
2002
  return [header, "_(none — every one scanned has a published translation)_"];
1827
2003
  };
1828
- const GAP_SCAN_WAVE_SIZE = 5;
1829
- const probeInWaves = async (nodes, probe) => {
1830
- const gaps = [];
1831
- for (let i = 0; i < nodes.length; i += GAP_SCAN_WAVE_SIZE) {
1832
- const wave = await Promise.all(nodes.slice(i, i + GAP_SCAN_WAVE_SIZE).map(probe));
1833
- for (const gap of wave) if (gap !== null) gaps.push(gap);
1834
- }
1835
- return gaps;
1836
- };
1837
2004
  const fetchGapCategories = async (subdomain, token, categoryId) => {
1838
2005
  if (categoryId !== void 0) {
1839
- const { category } = await helpCenterGet(subdomain, token, `/categories/${categoryId}`);
2006
+ const { category } = await helpCenterGet(subdomain, token, `/categories/${categoryId}`, { include: TRANSLATIONS_SIDELOAD });
1840
2007
  return {
1841
2008
  categories: [category],
1842
2009
  hasMore: false
1843
2010
  };
1844
2011
  }
1845
- const response = await helpCenterGet(subdomain, token, "/categories", buildCursorParams(100, void 0));
2012
+ const response = await helpCenterGet(subdomain, token, "/categories", {
2013
+ ...buildCursorParams(100, void 0),
2014
+ include: TRANSLATIONS_SIDELOAD
2015
+ });
1846
2016
  const categories = response.categories ?? [];
1847
2017
  return {
1848
2018
  categories,
1849
2019
  hasMore: extractPaginationMeta(response, categories.length).has_more
1850
2020
  };
1851
2021
  };
2022
+ const renderGapVerdict = (report, gapCount, unclassified) => {
2023
+ const { locale, scanned } = report;
2024
+ if (gapCount > 0) return `${gapCount} node(s) need a published "${locale}" translation. Fix a category with set_category_translation and a section with set_section_translation, passing draft: false to publish.`;
2025
+ if (scanned.categories + scanned.sections === 0 && unclassified > 0) return `Nothing could be classified, so this audit says nothing about "${locale}" either way. See the note below.`;
2026
+ const allClear = `No gaps: all ${scanned.categories} category/ies and ${scanned.sections} section(s) scanned have a published "${locale}" translation.`;
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;
2028
+ };
1852
2029
  const renderGapReport = (report) => {
1853
2030
  const { locale, categoryGaps, sectionGaps, scanned, found } = report;
1854
2031
  const gapCount = categoryGaps.length + sectionGaps.length;
1855
- const capped = scanned.categories < found.categories || scanned.sections < found.sections;
2032
+ const totalFound = found.categories + found.sections;
2033
+ const unclassified = totalFound - (scanned.categories + scanned.sections);
1856
2034
  return truncateIfNeeded([
1857
2035
  `# Translation gaps — "${locale}"`,
1858
2036
  "",
@@ -1861,8 +2039,8 @@ const renderGapReport = (report) => {
1861
2039
  "",
1862
2040
  ...renderGapLines("Sections", sectionGaps, scanned.sections, found.sections),
1863
2041
  "",
1864
- gapCount === 0 ? `No gaps: all ${scanned.categories} category/ies and ${scanned.sections} section(s) scanned have a published "${locale}" translation.` : `${gapCount} node(s) need a published "${locale}" translation. Fix a category with set_category_translation and a section with set_section_translation, passing draft: false to publish.`,
1865
- ...capped ? ["", `_Note: the scan stopped at its ${TRANSLATION_GAP_SCAN_MAX_NODES}-node cap, covering ${scanned.categories}/${found.categories} categories and ${scanned.sections}/${found.sections} sections. The rest were not checked narrow the scan with category_id, or raise ZENDESK_TRANSLATION_GAP_SCAN_MAX_NODES._`] : [],
2042
+ renderGapVerdict(report, gapCount, unclassified),
2043
+ ...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._`] : [],
1866
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._`] : []
1867
2045
  ].join("\n"));
1868
2046
  };
@@ -2302,10 +2480,10 @@ const createHelpCenterTools = (ctx) => {
2302
2480
  namespace: "help_center",
2303
2481
  readOnly: true,
2304
2482
  title: "Find Help Center Translation Gaps",
2305
- description: "Audit the Help Center tree for a target locale and report every category and section that has no translation, or one that is still an unpublished draft. Use it before or after translating articles: an article published in a second locale is unreachable while its parent section only exists in the source locale. Listing sections in that locale cannot answer this — a node with no translation is simply absent, without saying why, and a node whose translation is an unpublished draft may still be listed under its draft name — so this audit reads the draft flag on each node instead of trusting that listing. Costs one extra request per node scanned, capped (the report says so when the cap bites) — pass category_id to narrow it. Fix what it reports with set_section_translation / set_category_translation.",
2483
+ description: "Audit the Help Center tree for a target locale and report every category and section that has no translation, or one that is still an unpublished draft. Use it before or after translating articles: an article published in a second locale is unreachable while its parent section only exists in the source locale. Listing sections in that locale cannot answer this — a node with no translation is simply absent, without saying why, and a node whose translation is an unpublished draft may still be listed under its draft name — so this audit reads the draft flag on each node instead of trusting that listing. Costs two listings and covers up to 100 categories and 100 sections; past that only the first page of each level is audited, and the report says so — pass category_id to narrow it. Fix what it reports with set_section_translation / set_category_translation.",
2306
2484
  inputSchema: z.object({
2307
2485
  locale: z.string().describe("Locale to audit, e.g. \"fr\" or \"de\" — usually a non-default active locale of the Help Center (zendesk-hc://topology lists them). A locale that is not active is reported as a warning, since every node would then look untranslated."),
2308
- category_id: z.number().int().optional().describe("Restrict the audit to this category and the sections it contains (id from list_categories). Omit to sweep the whole tree, which costs one request per category and per section.")
2486
+ category_id: z.number().int().optional().describe("Restrict the audit to this category and the sections it contains (id from list_categories). Omit to sweep the whole tree, which costs two listings and covers up to 100 categories and 100 sections.")
2309
2487
  }),
2310
2488
  annotations: {
2311
2489
  readOnlyHint: true,
@@ -2319,14 +2497,17 @@ const createHelpCenterTools = (ctx) => {
2319
2497
  const [locales, categoryScope, sectionsRes] = await Promise.all([
2320
2498
  helpCenterGet(subdomain, token, "/locales"),
2321
2499
  fetchGapCategories(subdomain, token, category_id),
2322
- helpCenterGet(subdomain, token, sectionListPath(category_id, void 0), buildCursorParams(100, void 0))
2500
+ helpCenterGet(subdomain, token, sectionListPath(category_id, void 0), {
2501
+ ...buildCursorParams(100, void 0),
2502
+ include: TRANSLATIONS_SIDELOAD
2503
+ })
2323
2504
  ]);
2324
2505
  const allCategories = categoryScope.categories;
2325
2506
  const allSections = sectionsRes.sections ?? [];
2326
- const categories = allCategories.slice(0, TRANSLATION_GAP_SCAN_MAX_NODES);
2327
- const sections = allSections.slice(0, Math.max(0, TRANSLATION_GAP_SCAN_MAX_NODES - categories.length));
2328
- const categoryGaps = await probeInWaves(categories, async (category) => classifyGap(category, await listNodeTranslations(subdomain, token, "categories", category.id, locale), locale));
2329
- const sectionGaps = await probeInWaves(sections, async (section) => classifyGap(section, await listNodeTranslations(subdomain, token, "sections", section.id, locale), locale));
2507
+ const categories = withSideloadedTranslations(allCategories);
2508
+ const sections = withSideloadedTranslations(allSections);
2509
+ const categoryGaps = categories.flatMap((category) => classifyGap(category, category.translations, locale) ?? []);
2510
+ const sectionGaps = sections.flatMap((section) => classifyGap(section, section.translations, locale) ?? []);
2330
2511
  return { content: [{
2331
2512
  type: "text",
2332
2513
  text: renderGapReport({
@@ -4581,8 +4762,10 @@ const readJsonBody = (req, maxBodyBytes) => new Promise((resolve) => {
4581
4762
  const respondBodyError = (req, res, failure) => {
4582
4763
  const headers = failure.status === 413 ? { Connection: "close" } : {};
4583
4764
  sendJsonRpcError(res, failure.status, failure.rpcCode, failure.message, headers);
4584
- if (failure.status === 413) if (res.writableFinished) req.destroy();
4585
- else res.once("finish", () => req.destroy());
4765
+ if (failure.status === 413) {
4766
+ if (res.writableFinished) req.destroy();
4767
+ else res.once("finish", () => req.destroy());
4768
+ }
4586
4769
  };
4587
4770
  const SESSION_IDLE_TIMEOUT_MS = 18e5;
4588
4771
  const SESSION_SWEEP_INTERVAL_MS = 6e4;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fruggr/zendesk-mcp-server",
3
- "version": "2.17.1",
3
+ "version": "2.18.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,7 +69,7 @@
69
69
  "engines": {
70
70
  "node": ">=20"
71
71
  },
72
- "packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
72
+ "packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1",
73
73
  "dependencies": {
74
74
  "@modelcontextprotocol/sdk": "1.30.0",
75
75
  "cheerio": "1.2.0",
@@ -89,9 +89,9 @@
89
89
  },
90
90
  "devDependencies": {
91
91
  "@biomejs/biome": "2.5.7",
92
- "@semantic-release/changelog": "^6.0.3",
92
+ "@semantic-release/changelog": "^7.0.0",
93
93
  "@semantic-release/exec": "^7.1.0",
94
- "@semantic-release/git": "^10.0.1",
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",