@fruggr/zendesk-mcp-server 2.17.1 → 2.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +35 -23
  2. package/package.json +1 -1
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);
@@ -1802,6 +1801,7 @@ const nodeTranslationWriteText = (kind, nodeId, translation, created) => [
1802
1801
  "",
1803
1802
  formatNodeTranslationSummary(translation)
1804
1803
  ].join("\n");
1804
+ const TRANSLATIONS_SIDELOAD = "translations";
1805
1805
  const GAP_REASON_TEXT = {
1806
1806
  missing: "no translation",
1807
1807
  draft: "draft translation (not published)"
@@ -1819,40 +1819,49 @@ const classifyGap = (node, translations, locale) => {
1819
1819
  reason: "draft"
1820
1820
  } : null;
1821
1821
  };
1822
+ /**
1823
+ * Split a listing into the nodes that can be classified and the rest. A node
1824
+ * whose `translations` key is absent was answered without the sideload, and
1825
+ * reading that as "no translation" would report an entire Help Center as one big
1826
+ * gap — so it counts as unclassified and the report says how many.
1827
+ */
1828
+ const withSideloadedTranslations = (nodes) => nodes.filter((node) => Array.isArray(node.translations));
1822
1829
  const renderGapLines = (heading, gaps, scanned, found) => {
1823
1830
  const header = `## ${heading} (${scanned} scanned)`;
1824
1831
  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)_`];
1832
+ 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
1833
  return [header, "_(none — every one scanned has a published translation)_"];
1827
1834
  };
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
1835
  const fetchGapCategories = async (subdomain, token, categoryId) => {
1838
1836
  if (categoryId !== void 0) {
1839
- const { category } = await helpCenterGet(subdomain, token, `/categories/${categoryId}`);
1837
+ const { category } = await helpCenterGet(subdomain, token, `/categories/${categoryId}`, { include: TRANSLATIONS_SIDELOAD });
1840
1838
  return {
1841
1839
  categories: [category],
1842
1840
  hasMore: false
1843
1841
  };
1844
1842
  }
1845
- const response = await helpCenterGet(subdomain, token, "/categories", buildCursorParams(100, void 0));
1843
+ const response = await helpCenterGet(subdomain, token, "/categories", {
1844
+ ...buildCursorParams(100, void 0),
1845
+ include: TRANSLATIONS_SIDELOAD
1846
+ });
1846
1847
  const categories = response.categories ?? [];
1847
1848
  return {
1848
1849
  categories,
1849
1850
  hasMore: extractPaginationMeta(response, categories.length).has_more
1850
1851
  };
1851
1852
  };
1853
+ const renderGapVerdict = (report, gapCount, unclassified) => {
1854
+ const { locale, scanned } = report;
1855
+ 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.`;
1856
+ 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.`;
1857
+ const allClear = `No gaps: all ${scanned.categories} category/ies and ${scanned.sections} section(s) scanned have a published "${locale}" translation.`;
1858
+ 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;
1859
+ };
1852
1860
  const renderGapReport = (report) => {
1853
1861
  const { locale, categoryGaps, sectionGaps, scanned, found } = report;
1854
1862
  const gapCount = categoryGaps.length + sectionGaps.length;
1855
- const capped = scanned.categories < found.categories || scanned.sections < found.sections;
1863
+ const totalFound = found.categories + found.sections;
1864
+ const unclassified = totalFound - (scanned.categories + scanned.sections);
1856
1865
  return truncateIfNeeded([
1857
1866
  `# Translation gaps — "${locale}"`,
1858
1867
  "",
@@ -1861,8 +1870,8 @@ const renderGapReport = (report) => {
1861
1870
  "",
1862
1871
  ...renderGapLines("Sections", sectionGaps, scanned.sections, found.sections),
1863
1872
  "",
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._`] : [],
1873
+ renderGapVerdict(report, gapCount, unclassified),
1874
+ ...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
1875
  ...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
1876
  ].join("\n"));
1868
1877
  };
@@ -2302,10 +2311,10 @@ const createHelpCenterTools = (ctx) => {
2302
2311
  namespace: "help_center",
2303
2312
  readOnly: true,
2304
2313
  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.",
2314
+ 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
2315
  inputSchema: z.object({
2307
2316
  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.")
2317
+ 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
2318
  }),
2310
2319
  annotations: {
2311
2320
  readOnlyHint: true,
@@ -2319,14 +2328,17 @@ const createHelpCenterTools = (ctx) => {
2319
2328
  const [locales, categoryScope, sectionsRes] = await Promise.all([
2320
2329
  helpCenterGet(subdomain, token, "/locales"),
2321
2330
  fetchGapCategories(subdomain, token, category_id),
2322
- helpCenterGet(subdomain, token, sectionListPath(category_id, void 0), buildCursorParams(100, void 0))
2331
+ helpCenterGet(subdomain, token, sectionListPath(category_id, void 0), {
2332
+ ...buildCursorParams(100, void 0),
2333
+ include: TRANSLATIONS_SIDELOAD
2334
+ })
2323
2335
  ]);
2324
2336
  const allCategories = categoryScope.categories;
2325
2337
  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));
2338
+ const categories = withSideloadedTranslations(allCategories);
2339
+ const sections = withSideloadedTranslations(allSections);
2340
+ const categoryGaps = categories.flatMap((category) => classifyGap(category, category.translations, locale) ?? []);
2341
+ const sectionGaps = sections.flatMap((section) => classifyGap(section, section.translations, locale) ?? []);
2330
2342
  return { content: [{
2331
2343
  type: "text",
2332
2344
  text: renderGapReport({
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.17.2",
4
4
  "mcpName": "io.github.fruggr/zendesk-mcp-server",
5
5
  "description": "Deep Zendesk MCP server for your AI assistant: search, draft, update and translate Help Center articles and manage Support tickets end to end — comments, triage and image attachments.",
6
6
  "type": "module",