@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.
- package/dist/index.js +35 -23
- 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
|
|
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",
|
|
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
|
|
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
|
-
|
|
1865
|
-
...
|
|
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
|
|
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
|
|
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),
|
|
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
|
|
2327
|
-
const sections = allSections
|
|
2328
|
-
const categoryGaps =
|
|
2329
|
-
const sectionGaps =
|
|
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.
|
|
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",
|