@fruggr/zendesk-mcp-server 2.17.0 → 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 +109 -94
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { homedir, release } from "node:os";
|
|
|
6
6
|
import open from "open";
|
|
7
7
|
import { dirname, join } from "node:path";
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
9
10
|
import * as z from "zod/v4";
|
|
10
11
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
12
|
import * as cheerio from "cheerio";
|
|
@@ -125,7 +126,6 @@ const positiveIntEnv = (name, fallback) => {
|
|
|
125
126
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
126
127
|
};
|
|
127
128
|
const ARTICLE_RESOURCES_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES", 20);
|
|
128
|
-
const TRANSLATION_GAP_SCAN_MAX_NODES = positiveIntEnv("ZENDESK_TRANSLATION_GAP_SCAN_MAX_NODES", 60);
|
|
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);
|
|
131
131
|
const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
|
|
@@ -706,97 +706,99 @@ const parsePort = (raw, label) => {
|
|
|
706
706
|
if (!DIGITS_ONLY.test(raw)) throw new Error(`Invalid ${label} value. Expected an integer 0-65535.`);
|
|
707
707
|
return Number(raw);
|
|
708
708
|
};
|
|
709
|
-
const parsePortEnv = (raw, label) => raw === void 0
|
|
710
|
-
const
|
|
711
|
-
const
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
};
|
|
715
|
-
const
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
}
|
|
709
|
+
const parsePortEnv = (raw, label) => raw === void 0 ? void 0 : parsePort(raw, label);
|
|
710
|
+
const requireNonEmptyEnv = (name) => {
|
|
711
|
+
const raw = process.env[name];
|
|
712
|
+
if (raw === "") throw new Error(`Empty ${name}. Set it to a value, or unset it entirely.`);
|
|
713
|
+
return raw;
|
|
714
|
+
};
|
|
715
|
+
const CLI_OPTIONS = {
|
|
716
|
+
mode: { type: "string" },
|
|
717
|
+
namespace: {
|
|
718
|
+
type: "string",
|
|
719
|
+
multiple: true
|
|
720
|
+
},
|
|
721
|
+
tool: {
|
|
722
|
+
type: "string",
|
|
723
|
+
multiple: true
|
|
724
|
+
},
|
|
725
|
+
"log-level": { type: "string" },
|
|
726
|
+
"hc-resource-scheme": { type: "string" },
|
|
727
|
+
transport: { type: "string" },
|
|
728
|
+
host: { type: "string" },
|
|
729
|
+
port: { type: "string" },
|
|
730
|
+
"public-url": { type: "string" },
|
|
731
|
+
"cors-origin": {
|
|
732
|
+
type: "string",
|
|
733
|
+
multiple: true
|
|
734
|
+
},
|
|
735
|
+
"callback-port": { type: "string" },
|
|
736
|
+
"read-only": { type: "boolean" },
|
|
737
|
+
"no-topology": { type: "boolean" },
|
|
738
|
+
"no-promoted-articles": { type: "boolean" },
|
|
739
|
+
dev: { type: "boolean" }
|
|
740
|
+
};
|
|
741
|
+
new Set(Object.entries(CLI_OPTIONS).filter(([, spec]) => spec.type === "string").map(([name]) => `--${name}`));
|
|
742
|
+
const FIELD_BY_FLAG = /* @__PURE__ */ new Map([
|
|
743
|
+
["mode", "mode"],
|
|
744
|
+
["namespace", "namespaces"],
|
|
745
|
+
["tool", "tools"],
|
|
746
|
+
["log-level", "logLevel"],
|
|
747
|
+
["hc-resource-scheme", "hcResourceScheme"],
|
|
748
|
+
["transport", "transport"],
|
|
749
|
+
["host", "host"],
|
|
750
|
+
["public-url", "publicUrl"],
|
|
751
|
+
["cors-origin", "corsOrigins"]
|
|
728
752
|
]);
|
|
729
|
-
const
|
|
730
|
-
["
|
|
731
|
-
|
|
732
|
-
}],
|
|
733
|
-
["
|
|
734
|
-
result.hcResourceScheme = value;
|
|
735
|
-
}],
|
|
736
|
-
["--log-level", (result, value) => {
|
|
737
|
-
result.logLevel = value;
|
|
738
|
-
}],
|
|
739
|
-
["--transport", (result, value) => {
|
|
740
|
-
result.transport = value;
|
|
741
|
-
}],
|
|
742
|
-
["--host", (result, value) => {
|
|
743
|
-
result.host = value;
|
|
744
|
-
}],
|
|
745
|
-
["--public-url", (result, value) => {
|
|
746
|
-
result.publicUrl = value;
|
|
747
|
-
}],
|
|
748
|
-
["--port", (result, value) => {
|
|
749
|
-
result.port = parsePort(value, "--port");
|
|
750
|
-
}],
|
|
751
|
-
["--callback-port", (result, value) => {
|
|
752
|
-
result.callbackPort = parsePort(value, "--callback-port");
|
|
753
|
-
}],
|
|
754
|
-
["--namespace", appendTo("namespaces")],
|
|
755
|
-
["--tool", appendTo("tools")],
|
|
756
|
-
["--cors-origin", appendTo("corsOrigins")]
|
|
753
|
+
const STANDALONE_EFFECTS = /* @__PURE__ */ new Map([
|
|
754
|
+
["read-only", { readOnly: true }],
|
|
755
|
+
["no-topology", { topology: false }],
|
|
756
|
+
["no-promoted-articles", { promotedArticles: false }],
|
|
757
|
+
["dev", { dev: true }]
|
|
757
758
|
]);
|
|
758
759
|
const parseCliArgs = (args) => {
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
}
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
valued(result, next);
|
|
772
|
-
i++;
|
|
760
|
+
const { values, positionals } = parseArgs({
|
|
761
|
+
args,
|
|
762
|
+
options: CLI_OPTIONS,
|
|
763
|
+
allowPositionals: true
|
|
764
|
+
});
|
|
765
|
+
if (positionals.length > 1) throw new Error(`Expected one positional argument (the subdomain), got ${positionals.length}. A repeatable flag has to be repeated (--namespace tickets --namespace help_center); it does not take a space-separated list.`);
|
|
766
|
+
const result = { subdomain: positionals[0] };
|
|
767
|
+
for (const [flag, value] of Object.entries(values)) {
|
|
768
|
+
if (Array.isArray(value) ? value.includes("") : value === "") throw new Error(`Empty value for --${flag}. Provide a value, or omit the flag.`);
|
|
769
|
+
const effect = STANDALONE_EFFECTS.get(flag);
|
|
770
|
+
if (effect) {
|
|
771
|
+
Object.assign(result, effect);
|
|
773
772
|
continue;
|
|
774
773
|
}
|
|
775
|
-
|
|
774
|
+
const field = FIELD_BY_FLAG.get(flag);
|
|
775
|
+
if (field) Object.assign(result, { [field]: value });
|
|
776
776
|
}
|
|
777
|
+
if (values.port !== void 0) result.port = parsePort(values.port, "--port");
|
|
778
|
+
if (values["callback-port"] !== void 0) result.callbackPort = parsePort(values["callback-port"], "--callback-port");
|
|
777
779
|
return result;
|
|
778
780
|
};
|
|
779
781
|
const resolveTransportSettings = (cli) => {
|
|
780
782
|
const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
781
783
|
return {
|
|
782
|
-
transport: cli.transport ??
|
|
783
|
-
host: cli.host ??
|
|
784
|
-
port: cli.port ?? parsePortEnv(
|
|
785
|
-
publicUrl: cli.publicUrl ??
|
|
784
|
+
transport: cli.transport ?? requireNonEmptyEnv("TRANSPORT") ?? "stdio",
|
|
785
|
+
host: cli.host ?? requireNonEmptyEnv("HOST") ?? "0.0.0.0",
|
|
786
|
+
port: cli.port ?? parsePortEnv(requireNonEmptyEnv("PORT"), "PORT") ?? 3e3,
|
|
787
|
+
publicUrl: cli.publicUrl ?? requireNonEmptyEnv("PUBLIC_URL"),
|
|
786
788
|
corsOrigins: [...cli.corsOrigins ?? [], ...corsFromEnv]
|
|
787
789
|
};
|
|
788
790
|
};
|
|
789
791
|
const loadConfig = (argv = process.argv.slice(2)) => {
|
|
790
792
|
const cli = parseCliArgs(argv);
|
|
791
|
-
const subdomain = cli.subdomain ??
|
|
792
|
-
const oauthClientId =
|
|
793
|
+
const subdomain = cli.subdomain ?? requireNonEmptyEnv("ZENDESK_SUBDOMAIN") ?? "";
|
|
794
|
+
const oauthClientId = requireNonEmptyEnv("ZENDESK_OAUTH_CLIENT_ID") ?? `${subdomain}_zendesk`;
|
|
793
795
|
const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
|
|
794
|
-
const callbackPort = cli.callbackPort ?? parsePortEnv(
|
|
795
|
-
const hcResourceScheme = cli.hcResourceScheme ?? (
|
|
796
|
+
const callbackPort = cli.callbackPort ?? parsePortEnv(requireNonEmptyEnv("ZENDESK_OAUTH_CALLBACK_PORT"), "ZENDESK_OAUTH_CALLBACK_PORT");
|
|
797
|
+
const hcResourceScheme = cli.hcResourceScheme ?? requireNonEmptyEnv("HC_RESOURCE_SCHEME");
|
|
796
798
|
return ConfigSchema.parse({
|
|
797
799
|
subdomain,
|
|
798
800
|
oauthClientId,
|
|
799
|
-
logLevel: cli.logLevel ??
|
|
801
|
+
logLevel: cli.logLevel ?? requireNonEmptyEnv("LOG_LEVEL") ?? "info",
|
|
800
802
|
mode,
|
|
801
803
|
readOnly: cli.readOnly ?? false,
|
|
802
804
|
namespaces: cli.namespaces,
|
|
@@ -1799,6 +1801,7 @@ const nodeTranslationWriteText = (kind, nodeId, translation, created) => [
|
|
|
1799
1801
|
"",
|
|
1800
1802
|
formatNodeTranslationSummary(translation)
|
|
1801
1803
|
].join("\n");
|
|
1804
|
+
const TRANSLATIONS_SIDELOAD = "translations";
|
|
1802
1805
|
const GAP_REASON_TEXT = {
|
|
1803
1806
|
missing: "no translation",
|
|
1804
1807
|
draft: "draft translation (not published)"
|
|
@@ -1816,40 +1819,49 @@ const classifyGap = (node, translations, locale) => {
|
|
|
1816
1819
|
reason: "draft"
|
|
1817
1820
|
} : null;
|
|
1818
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));
|
|
1819
1829
|
const renderGapLines = (heading, gaps, scanned, found) => {
|
|
1820
1830
|
const header = `## ${heading} (${scanned} scanned)`;
|
|
1821
1831
|
if (gaps.length > 0) return [header, ...gaps.map((gap) => `- **${gap.name}** (${gap.id}) — ${GAP_REASON_TEXT[gap.reason]}`)];
|
|
1822
|
-
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)_`];
|
|
1823
1833
|
return [header, "_(none — every one scanned has a published translation)_"];
|
|
1824
1834
|
};
|
|
1825
|
-
const GAP_SCAN_WAVE_SIZE = 5;
|
|
1826
|
-
const probeInWaves = async (nodes, probe) => {
|
|
1827
|
-
const gaps = [];
|
|
1828
|
-
for (let i = 0; i < nodes.length; i += GAP_SCAN_WAVE_SIZE) {
|
|
1829
|
-
const wave = await Promise.all(nodes.slice(i, i + GAP_SCAN_WAVE_SIZE).map(probe));
|
|
1830
|
-
for (const gap of wave) if (gap !== null) gaps.push(gap);
|
|
1831
|
-
}
|
|
1832
|
-
return gaps;
|
|
1833
|
-
};
|
|
1834
1835
|
const fetchGapCategories = async (subdomain, token, categoryId) => {
|
|
1835
1836
|
if (categoryId !== void 0) {
|
|
1836
|
-
const { category } = await helpCenterGet(subdomain, token, `/categories/${categoryId}
|
|
1837
|
+
const { category } = await helpCenterGet(subdomain, token, `/categories/${categoryId}`, { include: TRANSLATIONS_SIDELOAD });
|
|
1837
1838
|
return {
|
|
1838
1839
|
categories: [category],
|
|
1839
1840
|
hasMore: false
|
|
1840
1841
|
};
|
|
1841
1842
|
}
|
|
1842
|
-
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
|
+
});
|
|
1843
1847
|
const categories = response.categories ?? [];
|
|
1844
1848
|
return {
|
|
1845
1849
|
categories,
|
|
1846
1850
|
hasMore: extractPaginationMeta(response, categories.length).has_more
|
|
1847
1851
|
};
|
|
1848
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
|
+
};
|
|
1849
1860
|
const renderGapReport = (report) => {
|
|
1850
1861
|
const { locale, categoryGaps, sectionGaps, scanned, found } = report;
|
|
1851
1862
|
const gapCount = categoryGaps.length + sectionGaps.length;
|
|
1852
|
-
const
|
|
1863
|
+
const totalFound = found.categories + found.sections;
|
|
1864
|
+
const unclassified = totalFound - (scanned.categories + scanned.sections);
|
|
1853
1865
|
return truncateIfNeeded([
|
|
1854
1866
|
`# Translation gaps — "${locale}"`,
|
|
1855
1867
|
"",
|
|
@@ -1858,8 +1870,8 @@ const renderGapReport = (report) => {
|
|
|
1858
1870
|
"",
|
|
1859
1871
|
...renderGapLines("Sections", sectionGaps, scanned.sections, found.sections),
|
|
1860
1872
|
"",
|
|
1861
|
-
|
|
1862
|
-
...
|
|
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._`] : [],
|
|
1863
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._`] : []
|
|
1864
1876
|
].join("\n"));
|
|
1865
1877
|
};
|
|
@@ -2299,10 +2311,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2299
2311
|
namespace: "help_center",
|
|
2300
2312
|
readOnly: true,
|
|
2301
2313
|
title: "Find Help Center Translation Gaps",
|
|
2302
|
-
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.",
|
|
2303
2315
|
inputSchema: z.object({
|
|
2304
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."),
|
|
2305
|
-
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.")
|
|
2306
2318
|
}),
|
|
2307
2319
|
annotations: {
|
|
2308
2320
|
readOnlyHint: true,
|
|
@@ -2316,14 +2328,17 @@ const createHelpCenterTools = (ctx) => {
|
|
|
2316
2328
|
const [locales, categoryScope, sectionsRes] = await Promise.all([
|
|
2317
2329
|
helpCenterGet(subdomain, token, "/locales"),
|
|
2318
2330
|
fetchGapCategories(subdomain, token, category_id),
|
|
2319
|
-
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
|
+
})
|
|
2320
2335
|
]);
|
|
2321
2336
|
const allCategories = categoryScope.categories;
|
|
2322
2337
|
const allSections = sectionsRes.sections ?? [];
|
|
2323
|
-
const categories = allCategories
|
|
2324
|
-
const sections = allSections
|
|
2325
|
-
const categoryGaps =
|
|
2326
|
-
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) ?? []);
|
|
2327
2342
|
return { content: [{
|
|
2328
2343
|
type: "text",
|
|
2329
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",
|