@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.
Files changed (2) hide show
  1. package/dist/index.js +109 -94
  2. 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 || raw === "" ? void 0 : parsePort(raw, label);
710
- const appendTo = (key) => (result, value) => {
711
- const list = result[key] ?? [];
712
- list.push(value);
713
- result[key] = list;
714
- };
715
- const STANDALONE_FLAGS = /* @__PURE__ */ new Map([
716
- ["--read-only", (result) => {
717
- result.readOnly = true;
718
- }],
719
- ["--no-topology", (result) => {
720
- result.topology = false;
721
- }],
722
- ["--no-promoted-articles", (result) => {
723
- result.promotedArticles = false;
724
- }],
725
- ["--dev", (result) => {
726
- result.dev = true;
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 VALUED_FLAGS = /* @__PURE__ */ new Map([
730
- ["--mode", (result, value) => {
731
- result.mode = value;
732
- }],
733
- ["--hc-resource-scheme", (result, value) => {
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 result = {};
760
- for (let i = 0; i < args.length; i++) {
761
- const arg = args[i];
762
- if (arg === void 0) continue;
763
- const standalone = STANDALONE_FLAGS.get(arg);
764
- if (standalone) {
765
- standalone(result);
766
- continue;
767
- }
768
- const valued = VALUED_FLAGS.get(arg);
769
- const next = args[i + 1];
770
- if (valued && next) {
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
- if (!arg.startsWith("-") && result.subdomain === void 0) result.subdomain = arg;
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 ?? process.env["TRANSPORT"] ?? "stdio",
783
- host: cli.host ?? process.env["HOST"] ?? "0.0.0.0",
784
- port: cli.port ?? parsePortEnv(process.env["PORT"], "PORT") ?? 3e3,
785
- publicUrl: cli.publicUrl ?? process.env["PUBLIC_URL"],
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 ?? process.env["ZENDESK_SUBDOMAIN"] ?? "";
792
- const oauthClientId = process.env["ZENDESK_OAUTH_CLIENT_ID"] ?? (subdomain ? `${subdomain}_zendesk` : "");
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(process.env["ZENDESK_OAUTH_CALLBACK_PORT"], "ZENDESK_OAUTH_CALLBACK_PORT");
795
- const hcResourceScheme = cli.hcResourceScheme ?? (process.env["HC_RESOURCE_SCHEME"] || void 0);
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 ?? process.env["LOG_LEVEL"] ?? "info",
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 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)_`];
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", buildCursorParams(100, void 0));
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 capped = scanned.categories < found.categories || scanned.sections < found.sections;
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
- 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.`,
1862
- ...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._`] : [],
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 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.",
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 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.")
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), buildCursorParams(100, 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.slice(0, TRANSLATION_GAP_SCAN_MAX_NODES);
2324
- const sections = allSections.slice(0, Math.max(0, TRANSLATION_GAP_SCAN_MAX_NODES - categories.length));
2325
- const categoryGaps = await probeInWaves(categories, async (category) => classifyGap(category, await listNodeTranslations(subdomain, token, "categories", category.id, locale), locale));
2326
- 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) ?? []);
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.0",
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",