@fruggr/zendesk-mcp-server 2.16.1 → 2.17.1

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 (3) hide show
  1. package/README.md +159 -288
  2. package/dist/index.js +401 -103
  3. package/package.json +11 -6
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,8 @@ 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 MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5 * 1024 * 1024);
129
+ const TRANSLATION_GAP_SCAN_MAX_NODES = positiveIntEnv("ZENDESK_TRANSLATION_GAP_SCAN_MAX_NODES", 60);
130
+ const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5242880);
129
131
  const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("ZENDESK_MAX_EMBEDDED_IMAGES", 10);
130
132
  const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
131
133
  const REORDER_CONFIRM_THRESHOLD = positiveIntEnv("ZENDESK_REORDER_CONFIRM_THRESHOLD", 20);
@@ -137,7 +139,7 @@ const getOAuthUrls = (subdomain) => ({
137
139
  });
138
140
  //#endregion
139
141
  //#region src/auth/browser-oauth.ts
140
- const AUTH_TIMEOUT_MS = 300 * 1e3;
142
+ const AUTH_TIMEOUT_MS = 3e5;
141
143
  /** Best-effort WSL detection: WSL kernels carry "microsoft" in /proc/version. */
142
144
  const detectWsl = () => {
143
145
  if (process.platform !== "linux") return false;
@@ -427,8 +429,12 @@ const appDirSegments = () => {
427
429
  };
428
430
  const configDir = () => {
429
431
  const segments = appDirSegments();
430
- if (isWindows) return join(process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming"), ...segments);
431
- return join(process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"), ...segments);
432
+ if (isWindows) {
433
+ const base = process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming");
434
+ return join(base, ...segments);
435
+ }
436
+ const base = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config");
437
+ return join(base, ...segments);
432
438
  };
433
439
  const safeName = (subdomain) => subdomain.replace(/[^a-z0-9-]/gi, "_");
434
440
  /**
@@ -479,7 +485,7 @@ const clearToken = (path, logger = silentLogger) => {
479
485
  //#endregion
480
486
  //#region src/auth/token-store.ts
481
487
  const EXPIRY_SKEW_MS = 6e4;
482
- const SCHEDULED_REFRESH_MS = 14400 * 1e3;
488
+ const SCHEDULED_REFRESH_MS = 144e5;
483
489
  const createAuthRequiredError = (authorizeUrl) => Object.assign(/* @__PURE__ */ new Error("Zendesk authentication required. A browser window should have opened for you to sign in. If it did not, open this URL in your browser, then retry your request:\n" + authorizeUrl), {
484
490
  name: "AuthRequiredError",
485
491
  authorizeUrl
@@ -701,97 +707,99 @@ const parsePort = (raw, label) => {
701
707
  if (!DIGITS_ONLY.test(raw)) throw new Error(`Invalid ${label} value. Expected an integer 0-65535.`);
702
708
  return Number(raw);
703
709
  };
704
- const parsePortEnv = (raw, label) => raw === void 0 || raw === "" ? void 0 : parsePort(raw, label);
705
- const appendTo = (key) => (result, value) => {
706
- const list = result[key] ?? [];
707
- list.push(value);
708
- result[key] = list;
709
- };
710
- const STANDALONE_FLAGS = /* @__PURE__ */ new Map([
711
- ["--read-only", (result) => {
712
- result.readOnly = true;
713
- }],
714
- ["--no-topology", (result) => {
715
- result.topology = false;
716
- }],
717
- ["--no-promoted-articles", (result) => {
718
- result.promotedArticles = false;
719
- }],
720
- ["--dev", (result) => {
721
- result.dev = true;
722
- }]
710
+ const parsePortEnv = (raw, label) => raw === void 0 ? void 0 : parsePort(raw, label);
711
+ const requireNonEmptyEnv = (name) => {
712
+ const raw = process.env[name];
713
+ if (raw === "") throw new Error(`Empty ${name}. Set it to a value, or unset it entirely.`);
714
+ return raw;
715
+ };
716
+ const CLI_OPTIONS = {
717
+ mode: { type: "string" },
718
+ namespace: {
719
+ type: "string",
720
+ multiple: true
721
+ },
722
+ tool: {
723
+ type: "string",
724
+ multiple: true
725
+ },
726
+ "log-level": { type: "string" },
727
+ "hc-resource-scheme": { type: "string" },
728
+ transport: { type: "string" },
729
+ host: { type: "string" },
730
+ port: { type: "string" },
731
+ "public-url": { type: "string" },
732
+ "cors-origin": {
733
+ type: "string",
734
+ multiple: true
735
+ },
736
+ "callback-port": { type: "string" },
737
+ "read-only": { type: "boolean" },
738
+ "no-topology": { type: "boolean" },
739
+ "no-promoted-articles": { type: "boolean" },
740
+ dev: { type: "boolean" }
741
+ };
742
+ new Set(Object.entries(CLI_OPTIONS).filter(([, spec]) => spec.type === "string").map(([name]) => `--${name}`));
743
+ const FIELD_BY_FLAG = /* @__PURE__ */ new Map([
744
+ ["mode", "mode"],
745
+ ["namespace", "namespaces"],
746
+ ["tool", "tools"],
747
+ ["log-level", "logLevel"],
748
+ ["hc-resource-scheme", "hcResourceScheme"],
749
+ ["transport", "transport"],
750
+ ["host", "host"],
751
+ ["public-url", "publicUrl"],
752
+ ["cors-origin", "corsOrigins"]
723
753
  ]);
724
- const VALUED_FLAGS = /* @__PURE__ */ new Map([
725
- ["--mode", (result, value) => {
726
- result.mode = value;
727
- }],
728
- ["--hc-resource-scheme", (result, value) => {
729
- result.hcResourceScheme = value;
730
- }],
731
- ["--log-level", (result, value) => {
732
- result.logLevel = value;
733
- }],
734
- ["--transport", (result, value) => {
735
- result.transport = value;
736
- }],
737
- ["--host", (result, value) => {
738
- result.host = value;
739
- }],
740
- ["--public-url", (result, value) => {
741
- result.publicUrl = value;
742
- }],
743
- ["--port", (result, value) => {
744
- result.port = parsePort(value, "--port");
745
- }],
746
- ["--callback-port", (result, value) => {
747
- result.callbackPort = parsePort(value, "--callback-port");
748
- }],
749
- ["--namespace", appendTo("namespaces")],
750
- ["--tool", appendTo("tools")],
751
- ["--cors-origin", appendTo("corsOrigins")]
754
+ const STANDALONE_EFFECTS = /* @__PURE__ */ new Map([
755
+ ["read-only", { readOnly: true }],
756
+ ["no-topology", { topology: false }],
757
+ ["no-promoted-articles", { promotedArticles: false }],
758
+ ["dev", { dev: true }]
752
759
  ]);
753
760
  const parseCliArgs = (args) => {
754
- const result = {};
755
- for (let i = 0; i < args.length; i++) {
756
- const arg = args[i];
757
- if (arg === void 0) continue;
758
- const standalone = STANDALONE_FLAGS.get(arg);
759
- if (standalone) {
760
- standalone(result);
761
- continue;
762
- }
763
- const valued = VALUED_FLAGS.get(arg);
764
- const next = args[i + 1];
765
- if (valued && next) {
766
- valued(result, next);
767
- i++;
761
+ const { values, positionals } = parseArgs({
762
+ args,
763
+ options: CLI_OPTIONS,
764
+ allowPositionals: true
765
+ });
766
+ 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.`);
767
+ const result = { subdomain: positionals[0] };
768
+ for (const [flag, value] of Object.entries(values)) {
769
+ if (Array.isArray(value) ? value.includes("") : value === "") throw new Error(`Empty value for --${flag}. Provide a value, or omit the flag.`);
770
+ const effect = STANDALONE_EFFECTS.get(flag);
771
+ if (effect) {
772
+ Object.assign(result, effect);
768
773
  continue;
769
774
  }
770
- if (!arg.startsWith("-") && result.subdomain === void 0) result.subdomain = arg;
775
+ const field = FIELD_BY_FLAG.get(flag);
776
+ if (field) Object.assign(result, { [field]: value });
771
777
  }
778
+ if (values.port !== void 0) result.port = parsePort(values.port, "--port");
779
+ if (values["callback-port"] !== void 0) result.callbackPort = parsePort(values["callback-port"], "--callback-port");
772
780
  return result;
773
781
  };
774
782
  const resolveTransportSettings = (cli) => {
775
783
  const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
776
784
  return {
777
- transport: cli.transport ?? process.env["TRANSPORT"] ?? "stdio",
778
- host: cli.host ?? process.env["HOST"] ?? "0.0.0.0",
779
- port: cli.port ?? parsePortEnv(process.env["PORT"], "PORT") ?? 3e3,
780
- publicUrl: cli.publicUrl ?? process.env["PUBLIC_URL"],
785
+ transport: cli.transport ?? requireNonEmptyEnv("TRANSPORT") ?? "stdio",
786
+ host: cli.host ?? requireNonEmptyEnv("HOST") ?? "0.0.0.0",
787
+ port: cli.port ?? parsePortEnv(requireNonEmptyEnv("PORT"), "PORT") ?? 3e3,
788
+ publicUrl: cli.publicUrl ?? requireNonEmptyEnv("PUBLIC_URL"),
781
789
  corsOrigins: [...cli.corsOrigins ?? [], ...corsFromEnv]
782
790
  };
783
791
  };
784
792
  const loadConfig = (argv = process.argv.slice(2)) => {
785
793
  const cli = parseCliArgs(argv);
786
- const subdomain = cli.subdomain ?? process.env["ZENDESK_SUBDOMAIN"] ?? "";
787
- const oauthClientId = process.env["ZENDESK_OAUTH_CLIENT_ID"] ?? (subdomain ? `${subdomain}_zendesk` : "");
794
+ const subdomain = cli.subdomain ?? requireNonEmptyEnv("ZENDESK_SUBDOMAIN") ?? "";
795
+ const oauthClientId = requireNonEmptyEnv("ZENDESK_OAUTH_CLIENT_ID") ?? `${subdomain}_zendesk`;
788
796
  const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
789
- const callbackPort = cli.callbackPort ?? parsePortEnv(process.env["ZENDESK_OAUTH_CALLBACK_PORT"], "ZENDESK_OAUTH_CALLBACK_PORT");
790
- const hcResourceScheme = cli.hcResourceScheme ?? (process.env["HC_RESOURCE_SCHEME"] || void 0);
797
+ const callbackPort = cli.callbackPort ?? parsePortEnv(requireNonEmptyEnv("ZENDESK_OAUTH_CALLBACK_PORT"), "ZENDESK_OAUTH_CALLBACK_PORT");
798
+ const hcResourceScheme = cli.hcResourceScheme ?? requireNonEmptyEnv("HC_RESOURCE_SCHEME");
791
799
  return ConfigSchema.parse({
792
800
  subdomain,
793
801
  oauthClientId,
794
- logLevel: cli.logLevel ?? process.env["LOG_LEVEL"] ?? "info",
802
+ logLevel: cli.logLevel ?? requireNonEmptyEnv("LOG_LEVEL") ?? "info",
795
803
  mode,
796
804
  readOnly: cli.readOnly ?? false,
797
805
  namespaces: cli.namespaces,
@@ -1259,6 +1267,13 @@ const formatTranslation = (translation) => [
1259
1267
  "",
1260
1268
  translation.body
1261
1269
  ].join("\n");
1270
+ const formatNodeTranslationSummary = (translation) => [
1271
+ `## Translation: ${translation.locale} (${translation.id})`,
1272
+ `- **Name**: ${translation.title}`,
1273
+ `- **Description**: ${translation.body ? "set" : "empty"}`,
1274
+ `- **Draft**: ${translation.draft}`,
1275
+ `- **Updated**: ${translation.updated_at}`
1276
+ ].join("\n");
1262
1277
  const formatCategory = (category) => `- **${category.name}** (${category.id}) — ${category.description || "No description"}`;
1263
1278
  const formatSection = (section) => `- **${section.name}** (${section.id}) — Category: ${section.category_id} — ${section.description || "No description"}`;
1264
1279
  const formatView = (view, count) => {
@@ -1359,12 +1374,14 @@ const fetchPromotedArticles = async (subdomain, token, maxPages = ARTICLE_RESOUR
1359
1374
  * character limit. Reuses the same formatting as the `get_article` tool.
1360
1375
  */
1361
1376
  const fetchArticleMarkdown = async (subdomain, token, id, locale) => {
1362
- const { article } = await helpCenterGet(subdomain, token, locale ? `/${locale}/articles/${id}` : `/articles/${id}`);
1363
- return truncateIfNeeded([
1377
+ const path = locale ? `/${locale}/articles/${id}` : `/articles/${id}`;
1378
+ const { article } = await helpCenterGet(subdomain, token, path);
1379
+ const text = [
1364
1380
  formatArticleSummary(article),
1365
1381
  "",
1366
1382
  htmlToMarkdown(article.body)
1367
- ].join("\n"));
1383
+ ].join("\n");
1384
+ return truncateIfNeeded(text);
1368
1385
  };
1369
1386
  /**
1370
1387
  * Build an article-resources provider. `listPromoted` holds a memoized-promise
@@ -1592,7 +1609,7 @@ const renderAdminSection = (items, denied, deniedNote) => {
1592
1609
  };
1593
1610
  /** Render the topology as a compact Markdown document for the LLM context. */
1594
1611
  const formatTopology = (data) => {
1595
- return truncateIfNeeded([
1612
+ const text = [
1596
1613
  `# Zendesk Help Center topology — ${data.subdomain}`,
1597
1614
  "",
1598
1615
  `**Your access**: ${data.currentUser.name} (id ${data.currentUser.id}), role "${data.currentUser.role}".`,
@@ -1609,7 +1626,8 @@ const formatTopology = (data) => {
1609
1626
  "",
1610
1627
  "## Permission groups",
1611
1628
  ...renderAdminSection(data.permissionGroups.map(formatPermissionGroup), data.permissionGroupsDenied, "_Unavailable: listing permission groups requires Guide-admin / Help Center manager rights, which this token lacks (HTTP 403). To create or edit an article, reuse the permission_group_id of an existing article (get_article)._")
1612
- ].join("\n"));
1629
+ ].join("\n");
1630
+ return truncateIfNeeded(text);
1613
1631
  };
1614
1632
  /**
1615
1633
  * Build a topology provider holding a memoized-promise cache (TTL
@@ -1737,6 +1755,117 @@ const isPlacedAsRequested = (effectiveAfter, movedId, target, referenceId) => {
1737
1755
  //#region src/tools/help-center.ts
1738
1756
  const ARTICLE_ID_DESC = "Article ID — the numeric id of the Help Center article. Obtain it from list_articles or search_articles.";
1739
1757
  const listTranslations = (subdomain, token, articleId) => helpCenterGet(subdomain, token, `/articles/${articleId}/translations`).then((res) => res.translations);
1758
+ const NODE_LABEL = {
1759
+ sections: "section",
1760
+ categories: "category"
1761
+ };
1762
+ const listNodeTranslations = (subdomain, token, kind, nodeId, locale) => helpCenterGet(subdomain, token, `/${kind}/${nodeId}/translations`, locale ? { locales: locale.toLowerCase() } : void 0).then((res) => res.translations ?? []);
1763
+ const findTranslation = (translations, locale) => {
1764
+ const wanted = locale.toLowerCase();
1765
+ return translations.find((t) => t.locale.toLowerCase() === wanted);
1766
+ };
1767
+ /**
1768
+ * Create-or-update a section/category translation in one call. The POST-vs-PUT
1769
+ * probe spares the caller a listing round-trip, or the 400 a duplicate POST
1770
+ * returns. Only the fields passed are sent on update, so omitting `description`
1771
+ * never blanks it and omitting `draft` never (un)publishes by accident.
1772
+ */
1773
+ const upsertNodeTranslation = async (subdomain, token, kind, nodeId, input) => {
1774
+ const { locale, name, description, draft } = input;
1775
+ const existing = findTranslation(await listNodeTranslations(subdomain, token, kind, nodeId, locale), locale);
1776
+ if (!existing) {
1777
+ if (name === void 0) throw new Error(`${NODE_LABEL[kind]} #${nodeId} has no "${locale}" translation yet, so one has to be created and "name" is required. Pass the localized name, or call list_${NODE_LABEL[kind]}_translations to see which locales already exist.`);
1778
+ const { translation } = await helpCenterPost(subdomain, token, `/${kind}/${nodeId}/translations`, { translation: {
1779
+ locale,
1780
+ title: name,
1781
+ body: description ?? "",
1782
+ draft: draft ?? false
1783
+ } });
1784
+ return {
1785
+ translation,
1786
+ created: true
1787
+ };
1788
+ }
1789
+ const updates = {};
1790
+ if (name !== void 0) updates["title"] = name;
1791
+ if (description !== void 0) updates["body"] = description;
1792
+ if (draft !== void 0) updates["draft"] = draft;
1793
+ if (Object.keys(updates).length === 0) throw new Error(`Nothing to write: ${NODE_LABEL[kind]} #${nodeId} already has a "${existing.locale}" translation, so pass at least one of "name", "description" or "draft" to change it (draft: false publishes it).`);
1794
+ const { translation } = await helpCenterPut(subdomain, token, `/${kind}/${nodeId}/translations/${existing.locale}`, { translation: updates });
1795
+ return {
1796
+ translation,
1797
+ created: false
1798
+ };
1799
+ };
1800
+ const nodeTranslationWriteText = (kind, nodeId, translation, created) => [
1801
+ `Translation ${created ? "created" : "updated"} for ${NODE_LABEL[kind]} #${nodeId} in "${translation.locale}" (${translation.draft ? "draft, not visible to end users" : "published"}).`,
1802
+ "",
1803
+ formatNodeTranslationSummary(translation)
1804
+ ].join("\n");
1805
+ const GAP_REASON_TEXT = {
1806
+ missing: "no translation",
1807
+ draft: "draft translation (not published)"
1808
+ };
1809
+ const classifyGap = (node, translations, locale) => {
1810
+ const translation = findTranslation(translations, locale);
1811
+ if (!translation) return {
1812
+ id: node.id,
1813
+ name: node.name,
1814
+ reason: "missing"
1815
+ };
1816
+ return translation.draft ? {
1817
+ id: node.id,
1818
+ name: node.name,
1819
+ reason: "draft"
1820
+ } : null;
1821
+ };
1822
+ const renderGapLines = (heading, gaps, scanned, found) => {
1823
+ const header = `## ${heading} (${scanned} scanned)`;
1824
+ 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)_`];
1826
+ return [header, "_(none — every one scanned has a published translation)_"];
1827
+ };
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
+ const fetchGapCategories = async (subdomain, token, categoryId) => {
1838
+ if (categoryId !== void 0) {
1839
+ const { category } = await helpCenterGet(subdomain, token, `/categories/${categoryId}`);
1840
+ return {
1841
+ categories: [category],
1842
+ hasMore: false
1843
+ };
1844
+ }
1845
+ const response = await helpCenterGet(subdomain, token, "/categories", buildCursorParams(100, void 0));
1846
+ const categories = response.categories ?? [];
1847
+ return {
1848
+ categories,
1849
+ hasMore: extractPaginationMeta(response, categories.length).has_more
1850
+ };
1851
+ };
1852
+ const renderGapReport = (report) => {
1853
+ const { locale, categoryGaps, sectionGaps, scanned, found } = report;
1854
+ const gapCount = categoryGaps.length + sectionGaps.length;
1855
+ const capped = scanned.categories < found.categories || scanned.sections < found.sections;
1856
+ return truncateIfNeeded([
1857
+ `# Translation gaps — "${locale}"`,
1858
+ "",
1859
+ ...report.activeLocales.some((l) => l.toLowerCase() === locale.toLowerCase()) ? [] : [`> ⚠ "${locale}" is not an active locale of this Help Center (active: ${report.activeLocales.join(", ")}), so everything below reads as untranslated. Check the spelling, or activate the language in Guide first.`, ""],
1860
+ ...renderGapLines("Categories", categoryGaps, scanned.categories, found.categories),
1861
+ "",
1862
+ ...renderGapLines("Sections", sectionGaps, scanned.sections, found.sections),
1863
+ "",
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._`] : [],
1866
+ ...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
+ ].join("\n"));
1868
+ };
1740
1869
  const largeArticleHint = (body, sectionCount) => {
1741
1870
  if (body.length < 3e3 && sectionCount < 4) return null;
1742
1871
  return [
@@ -1894,9 +2023,10 @@ const createHelpCenterTools = (ctx) => {
1894
2023
  const path = locale ? `/${locale}/articles/${article_id}` : `/articles/${article_id}`;
1895
2024
  const { article } = await helpCenterGet(subdomain, token, path);
1896
2025
  const translations = await listTranslations(subdomain, token, article_id);
2026
+ const text = (largeArticleHint(article.body, parseSections(article.body).length) ?? "") + formatArticle(article) + `\n\n**Available translations**: ${translations.map((t) => t.locale).join(", ")}`;
1897
2027
  return { content: [{
1898
2028
  type: "text",
1899
- text: truncateIfNeeded((largeArticleHint(article.body, parseSections(article.body).length) ?? "") + formatArticle(article) + `\n\n**Available translations**: ${translations.map((t) => t.locale).join(", ")}`)
2029
+ text: truncateIfNeeded(text)
1900
2030
  }] };
1901
2031
  }
1902
2032
  },
@@ -2002,9 +2132,10 @@ const createHelpCenterTools = (ctx) => {
2002
2132
  return `${formatArticleSummary(article)}\n- **Translations**: ${locales}`;
2003
2133
  }));
2004
2134
  const meta = extractPaginationMeta(response, articles.length);
2135
+ const text = [meta.count ? `Results: ${meta.count}${meta.has_more ? ` | More available (cursor: ${meta.after_cursor})` : ""}` : "", ...formatted].filter(Boolean).join("\n\n");
2005
2136
  return { content: [{
2006
2137
  type: "text",
2007
- text: truncateIfNeeded([meta.count ? `Results: ${meta.count}${meta.has_more ? ` | More available (cursor: ${meta.after_cursor})` : ""}` : "", ...formatted].filter(Boolean).join("\n\n"))
2138
+ text: truncateIfNeeded(text)
2008
2139
  }] };
2009
2140
  }
2010
2141
  },
@@ -2027,9 +2158,10 @@ const createHelpCenterTools = (ctx) => {
2027
2158
  const header = `Promoted (featured) articles: ${articles.length}`;
2028
2159
  const body = articles.length ? articles.map(formatArticleSummary).join("\n\n") : "_No promoted articles found._";
2029
2160
  const cost = `${pagesScanned} Zendesk API request${pagesScanned === 1 ? "" : "s"}`;
2161
+ const note = scanCostNote(truncated, pagesScanned, cost);
2030
2162
  return { content: [{
2031
2163
  type: "text",
2032
- text: truncateIfNeeded(`${header}\n\n${body}${scanCostNote(truncated, pagesScanned, cost)}`)
2164
+ text: truncateIfNeeded(`${header}\n\n${body}${note}`)
2033
2165
  }] };
2034
2166
  }
2035
2167
  },
@@ -2049,9 +2181,10 @@ const createHelpCenterTools = (ctx) => {
2049
2181
  handler: async (params) => {
2050
2182
  const { article_id } = params;
2051
2183
  const token = await getToken();
2184
+ const translations = await listTranslations(subdomain, token, article_id);
2052
2185
  return { content: [{
2053
2186
  type: "text",
2054
- text: formatList(await listTranslations(subdomain, token, article_id), formatTranslationSummary)
2187
+ text: formatList(translations, formatTranslationSummary)
2055
2188
  }] };
2056
2189
  }
2057
2190
  },
@@ -2118,6 +2251,160 @@ const createHelpCenterTools = (ctx) => {
2118
2251
  }] };
2119
2252
  }
2120
2253
  },
2254
+ {
2255
+ name: "list_section_translations",
2256
+ namespace: "help_center",
2257
+ readOnly: true,
2258
+ title: "List Section Translations",
2259
+ description: "List the translations of a Help Center section: for each locale, the localized name, whether a description is set, and whether the translation is published or still a draft. Reach for this when a section looks wrong in a locale, because list_sections with that locale cannot settle it: a section with no translation is omitted from it, while a section whose translation is an unpublished draft may still be listed there under the draft name — so appearing in that listing does not mean published, and the draft flag here is what decides. Fix either case with set_section_translation; to sweep every category and section at once, use find_translation_gaps.",
2260
+ inputSchema: z.object({ section_id: z.number().int().describe("Section ID — the numeric id of the Help Center section. Obtain it from list_sections or the zendesk-hc://topology resource.") }),
2261
+ annotations: {
2262
+ readOnlyHint: true,
2263
+ destructiveHint: false,
2264
+ idempotentHint: true,
2265
+ openWorldHint: true
2266
+ },
2267
+ handler: async (params) => {
2268
+ const { section_id } = params;
2269
+ const token = await getToken();
2270
+ const translations = await listNodeTranslations(subdomain, token, "sections", section_id);
2271
+ return { content: [{
2272
+ type: "text",
2273
+ text: formatList(translations, formatNodeTranslationSummary)
2274
+ }] };
2275
+ }
2276
+ },
2277
+ {
2278
+ name: "list_category_translations",
2279
+ namespace: "help_center",
2280
+ readOnly: true,
2281
+ title: "List Category Translations",
2282
+ description: "List the translations of a Help Center category: for each locale, the localized name, whether a description is set, and whether the translation is published or still a draft. Reach for this when a category looks wrong in a locale, because list_categories with that locale cannot settle it: a category with no translation is omitted from it, while a category whose translation is an unpublished draft may still be listed there under the draft name — so appearing in that listing does not mean published, and the draft flag here is what decides. Fix either case with set_category_translation; to sweep every category and section at once, use find_translation_gaps.",
2283
+ inputSchema: z.object({ category_id: z.number().int().describe("Category ID — the numeric id of the Help Center category. Obtain it from list_categories or the zendesk-hc://topology resource.") }),
2284
+ annotations: {
2285
+ readOnlyHint: true,
2286
+ destructiveHint: false,
2287
+ idempotentHint: true,
2288
+ openWorldHint: true
2289
+ },
2290
+ handler: async (params) => {
2291
+ const { category_id } = params;
2292
+ const token = await getToken();
2293
+ const translations = await listNodeTranslations(subdomain, token, "categories", category_id);
2294
+ return { content: [{
2295
+ type: "text",
2296
+ text: formatList(translations, formatNodeTranslationSummary)
2297
+ }] };
2298
+ }
2299
+ },
2300
+ {
2301
+ name: "find_translation_gaps",
2302
+ namespace: "help_center",
2303
+ readOnly: true,
2304
+ 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.",
2306
+ inputSchema: z.object({
2307
+ 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.")
2309
+ }),
2310
+ annotations: {
2311
+ readOnlyHint: true,
2312
+ destructiveHint: false,
2313
+ idempotentHint: true,
2314
+ openWorldHint: true
2315
+ },
2316
+ handler: async (params) => {
2317
+ const { locale, category_id } = params;
2318
+ const token = await getToken();
2319
+ const [locales, categoryScope, sectionsRes] = await Promise.all([
2320
+ helpCenterGet(subdomain, token, "/locales"),
2321
+ fetchGapCategories(subdomain, token, category_id),
2322
+ helpCenterGet(subdomain, token, sectionListPath(category_id, void 0), buildCursorParams(100, void 0))
2323
+ ]);
2324
+ const allCategories = categoryScope.categories;
2325
+ 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));
2330
+ return { content: [{
2331
+ type: "text",
2332
+ text: renderGapReport({
2333
+ locale,
2334
+ activeLocales: locales.locales ?? [],
2335
+ categoryGaps,
2336
+ sectionGaps,
2337
+ scanned: {
2338
+ categories: categories.length,
2339
+ sections: sections.length
2340
+ },
2341
+ found: {
2342
+ categories: allCategories.length,
2343
+ sections: allSections.length
2344
+ },
2345
+ listingIncomplete: categoryScope.hasMore || extractPaginationMeta(sectionsRes, allSections.length).has_more
2346
+ })
2347
+ }] };
2348
+ }
2349
+ },
2350
+ {
2351
+ name: "set_section_translation",
2352
+ namespace: "help_center",
2353
+ readOnly: false,
2354
+ title: "Create or Update a Section Translation",
2355
+ description: "Create or update the translation of a Help Center section in one locale, and return the resulting translation (locale, localized name, draft state). Creates the translation when the locale has none and updates it otherwise, so no listing call is needed first; only the fields you pass are written, which makes \"publish this draft\" a single draft: false. Use it to make a section reachable in a locale where its articles are already translated — a gap find_translation_gaps reports and list_sections cannot explain.",
2356
+ inputSchema: z.object({
2357
+ section_id: z.number().int().describe("Section ID — the numeric id of the section whose translation to write. Obtain it from list_sections, find_translation_gaps or the zendesk-hc://topology resource."),
2358
+ locale: z.string().describe("Locale to write, e.g. \"fr\" or \"de\". Must be an active locale of the Help Center (zendesk-hc://topology lists them); list_section_translations shows which ones the section already has."),
2359
+ name: z.string().min(1).optional().describe("Localized section name for this locale (sent as the API's translation `title`). Required when the locale has no translation yet; omit on an existing one to leave its name untouched, for instance when only publishing a draft."),
2360
+ description: z.string().optional().describe("Localized section description for this locale (sent as the API's translation `body`). Omit to leave an existing description untouched; pass an empty string to clear it."),
2361
+ draft: z.boolean().optional().describe("Publication state: false publishes the translation, making the section visible to end users in this locale; true keeps (or puts) it back as a draft. Defaults to false when creating; omit on an existing translation to leave its state unchanged.")
2362
+ }),
2363
+ annotations: {
2364
+ readOnlyHint: false,
2365
+ destructiveHint: true,
2366
+ idempotentHint: true,
2367
+ openWorldHint: true
2368
+ },
2369
+ handler: async (params) => {
2370
+ const { section_id, ...input } = params;
2371
+ const token = await getToken();
2372
+ const { translation, created } = await upsertNodeTranslation(subdomain, token, "sections", section_id, input);
2373
+ return { content: [{
2374
+ type: "text",
2375
+ text: nodeTranslationWriteText("sections", section_id, translation, created)
2376
+ }] };
2377
+ }
2378
+ },
2379
+ {
2380
+ name: "set_category_translation",
2381
+ namespace: "help_center",
2382
+ readOnly: false,
2383
+ title: "Create or Update a Category Translation",
2384
+ description: "Create or update the translation of a Help Center category in one locale, and return the resulting translation (locale, localized name, draft state). Creates the translation when the locale has none and updates it otherwise, so no listing call is needed first; only the fields you pass are written, which makes \"publish this draft\" a single draft: false. Use it to make a category reachable in a locale where its sections or articles are already translated — a gap find_translation_gaps reports and list_categories cannot explain.",
2385
+ inputSchema: z.object({
2386
+ category_id: z.number().int().describe("Category ID — the numeric id of the category whose translation to write. Obtain it from list_categories, find_translation_gaps or the zendesk-hc://topology resource."),
2387
+ locale: z.string().describe("Locale to write, e.g. \"fr\" or \"de\". Must be an active locale of the Help Center (zendesk-hc://topology lists them); list_category_translations shows which ones the category already has."),
2388
+ name: z.string().min(1).optional().describe("Localized category name for this locale (sent as the API's translation `title`). Required when the locale has no translation yet; omit on an existing one to leave its name untouched, for instance when only publishing a draft."),
2389
+ description: z.string().optional().describe("Localized category description for this locale (sent as the API's translation `body`). Omit to leave an existing description untouched; pass an empty string to clear it."),
2390
+ draft: z.boolean().optional().describe("Publication state: false publishes the translation, making the category visible to end users in this locale; true keeps (or puts) it back as a draft. Defaults to false when creating; omit on an existing translation to leave its state unchanged.")
2391
+ }),
2392
+ annotations: {
2393
+ readOnlyHint: false,
2394
+ destructiveHint: true,
2395
+ idempotentHint: true,
2396
+ openWorldHint: true
2397
+ },
2398
+ handler: async (params) => {
2399
+ const { category_id, ...input } = params;
2400
+ const token = await getToken();
2401
+ const { translation, created } = await upsertNodeTranslation(subdomain, token, "categories", category_id, input);
2402
+ return { content: [{
2403
+ type: "text",
2404
+ text: nodeTranslationWriteText("categories", category_id, translation, created)
2405
+ }] };
2406
+ }
2407
+ },
2121
2408
  {
2122
2409
  name: "list_permission_groups",
2123
2410
  namespace: "help_center",
@@ -2250,7 +2537,8 @@ const createHelpCenterTools = (ctx) => {
2250
2537
  const effective = await fetchSectionOrder(sectionId, locale, token);
2251
2538
  if (reference_article_id !== void 0) await assertReferenceInSection(effective, reference_article_id, article_id, sectionId, token);
2252
2539
  const targetLabel = needsReference ? `${target} article #${reference_article_id}` : target;
2253
- const writes = computePositionWrites(arrangeDesiredOrder(effective, article_id, target, reference_article_id), article_id, normalize);
2540
+ const desired = arrangeDesiredOrder(effective, article_id, target, reference_article_id);
2541
+ const writes = computePositionWrites(desired, article_id, normalize);
2254
2542
  if (writes.length === 0) return { content: [{
2255
2543
  type: "text",
2256
2544
  text: `Article #${article_id} is already positioned ${targetLabel} in section #${sectionId}. No changes made.`
@@ -2264,7 +2552,8 @@ const createHelpCenterTools = (ctx) => {
2264
2552
  text: `Reordering article #${article_id} to ${targetLabel} would reposition ${writes.length} articles in section #${sectionId}, above the safety threshold of ${REORDER_CONFIRM_THRESHOLD}. Re-run with confirm: true to proceed.`
2265
2553
  }] };
2266
2554
  const applied = await applyPositionWrites(writes, article_id, token);
2267
- if (!isPlacedAsRequested(await fetchSectionOrder(sectionId, locale, token), article_id, target, reference_article_id)) return { content: [{
2555
+ const after = await fetchSectionOrder(sectionId, locale, token);
2556
+ if (!isPlacedAsRequested(after, article_id, target, reference_article_id)) return { content: [{
2268
2557
  type: "text",
2269
2558
  text: autoSortNotice(sectionId, applied)
2270
2559
  }] };
@@ -2375,9 +2664,10 @@ const createHelpCenterTools = (ctx) => {
2375
2664
  },
2376
2665
  handler: async () => {
2377
2666
  const token = await getToken();
2667
+ const response = await helpCenterGet(subdomain, token, "/articles/labels");
2378
2668
  return { content: [{
2379
2669
  type: "text",
2380
- text: formatList((await helpCenterGet(subdomain, token, "/articles/labels")).labels ?? [], formatLabel)
2670
+ text: formatList(response.labels ?? [], formatLabel)
2381
2671
  }] };
2382
2672
  }
2383
2673
  },
@@ -2503,14 +2793,15 @@ const createHelpCenterTools = (ctx) => {
2503
2793
  const section = sections[section_index];
2504
2794
  if (!section) throw new Error(`Section index ${section_index} not found. Article has ${sections.length} section(s) (0-${Math.max(0, sections.length - 1)}).`);
2505
2795
  const content = format === "markdown" ? htmlToMarkdown(section.html) : section.html;
2796
+ const text = [
2797
+ section.headingTag ? `## [${section.index}] ${section.headingTag}: ${section.heading}` : `## [${section.index}] ${section.heading}`,
2798
+ `_Locale: ${locale} | Words: ${section.wordCount} | Format: ${format}_`,
2799
+ "",
2800
+ content
2801
+ ].join("\n");
2506
2802
  return { content: [{
2507
2803
  type: "text",
2508
- text: truncateIfNeeded([
2509
- section.headingTag ? `## [${section.index}] ${section.headingTag}: ${section.heading}` : `## [${section.index}] ${section.heading}`,
2510
- `_Locale: ${locale} | Words: ${section.wordCount} | Format: ${format}_`,
2511
- "",
2512
- content
2513
- ].join("\n"))
2804
+ text: truncateIfNeeded(text)
2514
2805
  }] };
2515
2806
  }
2516
2807
  },
@@ -2674,16 +2965,18 @@ const createSearchTools = (ctx) => {
2674
2965
  });
2675
2966
  const results = response.results ?? [];
2676
2967
  const meta = extractSearchPaginationMeta(response, per_page, page);
2968
+ const header = `Total: ${meta.count} | Page ${page} (${results.length} results)${meta.has_more ? ` | Next page: ${meta.after_cursor}` : ""}`;
2969
+ const body = results.map(formatSearchResult).join("\n\n");
2677
2970
  return { content: [{
2678
2971
  type: "text",
2679
- text: truncateIfNeeded([`Total: ${meta.count} | Page ${page} (${results.length} results)${meta.has_more ? ` | Next page: ${meta.after_cursor}` : ""}`, results.map(formatSearchResult).join("\n\n")].filter(Boolean).join("\n\n"))
2972
+ text: truncateIfNeeded([header, body].filter(Boolean).join("\n\n"))
2680
2973
  }] };
2681
2974
  }
2682
2975
  }];
2683
2976
  };
2684
2977
  //#endregion
2685
2978
  //#region src/tools/tickets.ts
2686
- const MAX_ATTACHMENT_MB = Number.parseFloat((MAX_ATTACHMENT_BYTES / (1024 * 1024)).toFixed(2));
2979
+ const MAX_ATTACHMENT_MB = Number.parseFloat((MAX_ATTACHMENT_BYTES / 1048576).toFixed(2));
2687
2980
  const formatReference = (attachment) => `**${attachment.file_name}** (id ${attachment.id}, ${attachment.content_type}, ${attachment.size} bytes) — ${attachment.content_url}`;
2688
2981
  const buildEmbeddedImageBlocks = async (subdomain, token, attachment, reference) => {
2689
2982
  const { data, contentType } = await fetchZendeskBinary(subdomain, token, attachment.content_url);
@@ -3302,9 +3595,10 @@ const createTicketTools = (ctx) => {
3302
3595
  const { problem_id } = params;
3303
3596
  const token = await getToken();
3304
3597
  const incidents = (await zendeskGet(subdomain, token, `/tickets/${problem_id}/incidents`)).tickets ?? [];
3598
+ const text = incidents.length > 0 ? `# Incidents linked to problem #${problem_id}\n\n${incidents.map(formatTicket).join("\n\n")}` : `No incidents linked to problem #${problem_id}.`;
3305
3599
  return { content: [{
3306
3600
  type: "text",
3307
- text: truncateIfNeeded(incidents.length > 0 ? `# Incidents linked to problem #${problem_id}\n\n${incidents.map(formatTicket).join("\n\n")}` : `No incidents linked to problem #${problem_id}.`)
3601
+ text: truncateIfNeeded(text)
3308
3602
  }] };
3309
3603
  }
3310
3604
  },
@@ -3370,9 +3664,10 @@ const createTicketTools = (ctx) => {
3370
3664
  throw error;
3371
3665
  }
3372
3666
  const policies = response.sla_policies ?? [];
3667
+ const meta = extractOffsetPaginationMeta(response, policies.length, per_page, page);
3373
3668
  return { content: [{
3374
3669
  type: "text",
3375
- text: formatList(policies, formatSlaPolicy, extractOffsetPaginationMeta(response, policies.length, per_page, page))
3670
+ text: formatList(policies, formatSlaPolicy, meta)
3376
3671
  }] };
3377
3672
  }
3378
3673
  },
@@ -3506,9 +3801,10 @@ const createTicketTools = (ctx) => {
3506
3801
  const token = await getToken();
3507
3802
  const response = await zendeskGet(subdomain, token, "/macros/active", buildOffsetParams(per_page, page));
3508
3803
  const macros = response.macros ?? [];
3804
+ const meta = extractOffsetPaginationMeta(response, macros.length, per_page, page);
3509
3805
  return { content: [{
3510
3806
  type: "text",
3511
- text: formatList(macros, formatMacro, extractOffsetPaginationMeta(response, macros.length, per_page, page))
3807
+ text: formatList(macros, formatMacro, meta)
3512
3808
  }] };
3513
3809
  }
3514
3810
  },
@@ -3944,7 +4240,8 @@ const startStdioTransport = async (server, logger = silentLogger) => {
3944
4240
  };
3945
4241
  //#endregion
3946
4242
  //#region src/dev/reload.ts
3947
- const toolsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "tools");
4243
+ const thisDir = dirname(fileURLToPath(import.meta.url));
4244
+ const toolsDir = join(thisDir, "..", "tools");
3948
4245
  const TOOL_MODULES = [
3949
4246
  {
3950
4247
  file: "tickets.ts",
@@ -4287,8 +4584,8 @@ const respondBodyError = (req, res, failure) => {
4287
4584
  if (failure.status === 413) if (res.writableFinished) req.destroy();
4288
4585
  else res.once("finish", () => req.destroy());
4289
4586
  };
4290
- const SESSION_IDLE_TIMEOUT_MS = 1800 * 1e3;
4291
- const SESSION_SWEEP_INTERVAL_MS = 60 * 1e3;
4587
+ const SESSION_IDLE_TIMEOUT_MS = 18e5;
4588
+ const SESSION_SWEEP_INTERVAL_MS = 6e4;
4292
4589
  const startHttpTransport = async (config, logger = silentLogger, options = {}) => {
4293
4590
  const metadata = buildOAuthMetadata(config, logger);
4294
4591
  const sessions = /* @__PURE__ */ new Map();
@@ -4442,7 +4739,8 @@ const main = async () => {
4442
4739
  await startDevServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
4443
4740
  return;
4444
4741
  }
4445
- await startStdioTransport(createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate), logger);
4742
+ const server = createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
4743
+ await startStdioTransport(server, logger);
4446
4744
  return;
4447
4745
  }
4448
4746
  if (config.dev) logger.warn("dev_mode_ignored_http");