@fruggr/zendesk-mcp-server 2.12.2 → 2.14.0

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 CHANGED
@@ -112,11 +112,12 @@ const positiveIntEnv = (name, fallback) => {
112
112
  const raw = process.env[name];
113
113
  if (raw === void 0 || raw.trim() === "") return fallback;
114
114
  const parsed = Number(raw);
115
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
115
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
116
116
  };
117
117
  const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5 * 1024 * 1024);
118
118
  const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("ZENDESK_MAX_EMBEDDED_IMAGES", 10);
119
119
  const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
120
+ const REORDER_CONFIRM_THRESHOLD = positiveIntEnv("ZENDESK_REORDER_CONFIRM_THRESHOLD", 20);
120
121
  const getBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2`;
121
122
  const getHelpCenterBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2/help_center`;
122
123
  const getOAuthUrls = (subdomain) => ({
@@ -1305,6 +1306,81 @@ const groupByNamespace = (tools) => {
1305
1306
  return grouped;
1306
1307
  };
1307
1308
  //#endregion
1309
+ //#region src/utils/article-order.ts
1310
+ const hasPositionInversion = (order) => {
1311
+ for (let i = 0; i < order.length - 1; i += 1) {
1312
+ const here = order[i];
1313
+ const next = order[i + 1];
1314
+ if (here && next && here.position > next.position) return true;
1315
+ }
1316
+ return false;
1317
+ };
1318
+ const arrangeDesiredOrder = (effective, movedId, target, referenceId) => {
1319
+ const moved = effective.find((a) => a.id === movedId);
1320
+ if (!moved) throw new Error(`Article ${movedId} is not in the section.`);
1321
+ const rest = effective.filter((a) => a.id !== movedId);
1322
+ let slot;
1323
+ if (target === "top") slot = 0;
1324
+ else if (target === "bottom") slot = rest.length;
1325
+ else {
1326
+ const refIndex = rest.findIndex((a) => a.id === referenceId);
1327
+ if (refIndex === -1) throw new Error(`Reference article ${referenceId} is not in the section.`);
1328
+ slot = target === "before" ? refIndex : refIndex + 1;
1329
+ }
1330
+ return [
1331
+ ...rest.slice(0, slot),
1332
+ moved,
1333
+ ...rest.slice(slot)
1334
+ ];
1335
+ };
1336
+ const computePositionWrites = (desired, movedId, normalize) => {
1337
+ const writes = [];
1338
+ if (normalize) {
1339
+ desired.forEach((a, index) => {
1340
+ if (a.position !== index) writes.push({
1341
+ id: a.id,
1342
+ position: index
1343
+ });
1344
+ });
1345
+ return writes;
1346
+ }
1347
+ const movedIndex = desired.findIndex((a) => a.id === movedId);
1348
+ const moved = desired[movedIndex];
1349
+ if (!moved) return writes;
1350
+ if (movedIndex === desired.length - 1) {
1351
+ const maxOthers = desired.slice(0, movedIndex).reduce((max, a) => Math.max(max, a.position), -1);
1352
+ if (moved.position > maxOthers) return writes;
1353
+ writes.push({
1354
+ id: moved.id,
1355
+ position: maxOthers + 1
1356
+ });
1357
+ return writes;
1358
+ }
1359
+ const left = desired[movedIndex - 1];
1360
+ let running = left ? left.position : -1;
1361
+ for (let i = movedIndex; i < desired.length; i += 1) {
1362
+ const article = desired[i];
1363
+ if (!article) break;
1364
+ if (i !== movedIndex && article.position > running) break;
1365
+ const target = running + 1;
1366
+ if (article.position !== target) writes.push({
1367
+ id: article.id,
1368
+ position: target
1369
+ });
1370
+ running = target;
1371
+ }
1372
+ return writes;
1373
+ };
1374
+ const isPlacedAsRequested = (effectiveAfter, movedId, target, referenceId) => {
1375
+ const movedIndex = effectiveAfter.findIndex((a) => a.id === movedId);
1376
+ if (movedIndex === -1) return false;
1377
+ if (target === "top") return movedIndex === 0;
1378
+ if (target === "bottom") return movedIndex === effectiveAfter.length - 1;
1379
+ const refIndex = effectiveAfter.findIndex((a) => a.id === referenceId);
1380
+ if (refIndex === -1) return false;
1381
+ return target === "before" ? movedIndex < refIndex : movedIndex > refIndex;
1382
+ };
1383
+ //#endregion
1308
1384
  //#region src/utils/article-sections.ts
1309
1385
  const HEADING_LEVELS = /* @__PURE__ */ new Set([
1310
1386
  "h1",
@@ -1402,6 +1478,7 @@ const markdownToHtml = (markdown) => {
1402
1478
  //#endregion
1403
1479
  //#region src/tools/help-center.ts
1404
1480
  const ARTICLE_ID_DESC = "Article ID — the numeric id of the Help Center article. Obtain it from list_articles or search_articles.";
1481
+ const listTranslations = (subdomain, token, articleId) => helpCenterGet(subdomain, token, `/articles/${articleId}/translations`).then((res) => res.translations);
1405
1482
  const largeArticleHint = (body, sectionCount) => {
1406
1483
  if (body.length < 3e3 && sectionCount < 4) return null;
1407
1484
  return [
@@ -1411,8 +1488,24 @@ const largeArticleHint = (body, sectionCount) => {
1411
1488
  ""
1412
1489
  ].join("\n");
1413
1490
  };
1491
+ const autoSortNotice = (sectionId, applied) => [applied === void 0 ? `Section #${sectionId} looks like it is sorted automatically, so a manual reorder would have no visible effect.` : `Wrote ${applied} article position(s), but the display order of section #${sectionId} did not change — the section is sorted automatically, so positions are ignored.`, "To order its articles manually: in Guide, open the section, choose \"Edit section\", set \"Order articles by\" to Manual, then re-run this tool."].join(" ");
1414
1492
  const createHelpCenterTools = (ctx) => {
1415
1493
  const { subdomain, getToken } = ctx;
1494
+ const fetchSectionOrder = async (sectionId, locale, token) => {
1495
+ const order = [];
1496
+ let cursor;
1497
+ do {
1498
+ const response = await helpCenterGet(subdomain, token, `/${locale}/sections/${sectionId}/articles`, buildCursorParams(100, cursor));
1499
+ const articles = response.articles ?? [];
1500
+ for (const article of articles) order.push({
1501
+ id: article.id,
1502
+ position: article.position
1503
+ });
1504
+ const meta = extractPaginationMeta(response, articles.length);
1505
+ cursor = meta.has_more && meta.after_cursor ? meta.after_cursor : void 0;
1506
+ } while (cursor);
1507
+ return order;
1508
+ };
1416
1509
  return [
1417
1510
  {
1418
1511
  name: "search_articles",
@@ -1468,7 +1561,7 @@ const createHelpCenterTools = (ctx) => {
1468
1561
  const token = await getToken();
1469
1562
  const path = locale ? `/${locale}/articles/${article_id}` : `/articles/${article_id}`;
1470
1563
  const { article } = await helpCenterGet(subdomain, token, path);
1471
- const { translations } = await helpCenterGet(subdomain, token, `/articles/${article_id}/translations`);
1564
+ const translations = await listTranslations(subdomain, token, article_id);
1472
1565
  return { content: [{
1473
1566
  type: "text",
1474
1567
  text: truncateIfNeeded((largeArticleHint(article.body, parseSections(article.body).length) ?? "") + formatArticle(article) + `\n\n**Available translations**: ${translations.map((t) => t.locale).join(", ")}`)
@@ -1575,8 +1668,7 @@ const createHelpCenterTools = (ctx) => {
1575
1668
  text: formatList(articles, formatArticleSummary, extractPaginationMeta(response, articles.length))
1576
1669
  }] };
1577
1670
  const formatted = await Promise.all(articles.map(async (article) => {
1578
- const { translations } = await helpCenterGet(subdomain, token, `/articles/${article.id}/translations`);
1579
- const locales = translations.map((t) => t.locale).join(", ");
1671
+ const locales = (await listTranslations(subdomain, token, article.id)).map((t) => t.locale).join(", ");
1580
1672
  return `${formatArticleSummary(article)}\n- **Translations**: ${locales}`;
1581
1673
  }));
1582
1674
  const meta = extractPaginationMeta(response, articles.length);
@@ -1602,10 +1694,9 @@ const createHelpCenterTools = (ctx) => {
1602
1694
  handler: async (params) => {
1603
1695
  const { article_id } = params;
1604
1696
  const token = await getToken();
1605
- const { translations } = await helpCenterGet(subdomain, token, `/articles/${article_id}/translations`);
1606
1697
  return { content: [{
1607
1698
  type: "text",
1608
- text: formatList(translations, formatTranslationSummary)
1699
+ text: formatList(await listTranslations(subdomain, token, article_id), formatTranslationSummary)
1609
1700
  }] };
1610
1701
  }
1611
1702
  },
@@ -1769,6 +1860,81 @@ const createHelpCenterTools = (ctx) => {
1769
1860
  }] };
1770
1861
  }
1771
1862
  },
1863
+ {
1864
+ name: "reorder_article",
1865
+ namespace: "help_center",
1866
+ readOnly: false,
1867
+ title: "Reorder Help Center Article",
1868
+ description: "Reorder an article within its current section by moving it relative to its siblings (top, bottom, or before/after another article), and return whether the new order was applied. This is the reliable way to satisfy \"put this article first/last\" requests: it writes the minimal set of article positions needed to make the order deterministic, because Zendesk leaves newly created articles tied at position 0 where a plain position update is silently ambiguous. It does NOT move the article to a different section — use update_article with section_id for that. Zendesk exposes no way to read whether a section is manually or automatically sorted, so when the section is sorted automatically (by date or alphabetically) the position writes are ignored; this tool detects that after the fact and returns guidance to switch the section to manual ordering in Guide. A move may reposition several neighbouring articles; when that count exceeds a configurable safety threshold the call is refused unless confirm is set to true.",
1869
+ inputSchema: z.object({
1870
+ article_id: z.number().int().describe("Article ID — the numeric id of the article to move within its section. Obtain it from list_articles or search_articles."),
1871
+ target: z.enum([
1872
+ "top",
1873
+ "bottom",
1874
+ "before",
1875
+ "after"
1876
+ ]).describe("Where to move the article relative to its section siblings: \"top\" (becomes first), \"bottom\" (becomes last), or \"before\"/\"after\" a specific reference article. \"before\" and \"after\" require reference_article_id."),
1877
+ reference_article_id: z.number().int().optional().describe("The sibling article to position next to when target is \"before\" or \"after\" (numeric id from list_articles). Must belong to the same section and differ from article_id; leave it unset for \"top\" or \"bottom\"."),
1878
+ normalize: z.boolean().default(false).describe("When true, also renumber every article in the section to contiguous positions (0, 1, 2, …) so the stored positions stay tidy. Defaults to false, which writes the fewest positions possible and lets gaps remain. Either way the confirmation threshold still applies."),
1879
+ confirm: z.boolean().default(false).describe("Safety guard for large reorders. When the move would rewrite more article positions than the configured threshold (ZENDESK_REORDER_CONFIRM_THRESHOLD, default 20), the tool refuses and reports the count until you pass true here. Has no effect on small reorders.")
1880
+ }),
1881
+ annotations: {
1882
+ readOnlyHint: false,
1883
+ destructiveHint: true,
1884
+ idempotentHint: true,
1885
+ openWorldHint: true
1886
+ },
1887
+ handler: async (params) => {
1888
+ const { article_id, target, reference_article_id, normalize = false, confirm = false } = params;
1889
+ const needsReference = target === "before" || target === "after";
1890
+ if (needsReference && reference_article_id === void 0) throw new Error(`target "${target}" requires reference_article_id (the article to move ${target}).`);
1891
+ if (!needsReference && reference_article_id !== void 0) throw new Error(`reference_article_id must be omitted when target is "${target}" (it only applies to "before"/"after").`);
1892
+ if (reference_article_id !== void 0 && reference_article_id === article_id) throw new Error("reference_article_id must differ from article_id.");
1893
+ const token = await getToken();
1894
+ const { article } = await helpCenterGet(subdomain, token, `/articles/${article_id}`);
1895
+ const sectionId = article.section_id;
1896
+ const locale = article.source_locale;
1897
+ const effective = await fetchSectionOrder(sectionId, locale, token);
1898
+ if (needsReference && !effective.some((a) => a.id === reference_article_id)) {
1899
+ let detail = "was not found";
1900
+ try {
1901
+ const { article: ref } = await helpCenterGet(subdomain, token, `/articles/${reference_article_id}`);
1902
+ detail = `is in section #${ref.section_id}, not section #${sectionId}`;
1903
+ } catch {}
1904
+ throw new Error(`Reference article #${reference_article_id} ${detail}. It must be in the same section (#${sectionId}) as article #${article_id}.`);
1905
+ }
1906
+ const targetLabel = needsReference ? `${target} article #${reference_article_id}` : target;
1907
+ const writes = computePositionWrites(arrangeDesiredOrder(effective, article_id, target, reference_article_id), article_id, normalize);
1908
+ if (writes.length === 0) return { content: [{
1909
+ type: "text",
1910
+ text: `Article #${article_id} is already positioned ${targetLabel} in section #${sectionId}. No changes made.`
1911
+ }] };
1912
+ if (hasPositionInversion(effective)) return { content: [{
1913
+ type: "text",
1914
+ text: autoSortNotice(sectionId)
1915
+ }] };
1916
+ if (writes.length > REORDER_CONFIRM_THRESHOLD && confirm !== true) return { content: [{
1917
+ type: "text",
1918
+ 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.`
1919
+ }] };
1920
+ let applied = 0;
1921
+ for (const write of writes) try {
1922
+ await helpCenterPut(subdomain, token, `/articles/${write.id}`, { article: { position: write.position } });
1923
+ applied += 1;
1924
+ } catch (error) {
1925
+ const reason = error instanceof Error ? error.message : String(error);
1926
+ throw new Error(`Reorder of article #${article_id} failed after ${applied}/${writes.length} position write(s) (on article #${write.id}): ${reason} Positions are written absolutely, so re-running the identical call is safe and resumes where it stopped.`, { cause: error });
1927
+ }
1928
+ if (!isPlacedAsRequested(await fetchSectionOrder(sectionId, locale, token), article_id, target, reference_article_id)) return { content: [{
1929
+ type: "text",
1930
+ text: autoSortNotice(sectionId, applied)
1931
+ }] };
1932
+ return { content: [{
1933
+ type: "text",
1934
+ text: `Article #${article_id} moved to ${targetLabel} in section #${sectionId} (${applied} article${applied === 1 ? "" : "s"} repositioned).`
1935
+ }] };
1936
+ }
1937
+ },
1772
1938
  {
1773
1939
  name: "archive_article",
1774
1940
  namespace: "help_center",
@@ -1953,7 +2119,7 @@ const createHelpCenterTools = (ctx) => {
1953
2119
  const { article } = await helpCenterGet(subdomain, token, `/articles/${article_id}`);
1954
2120
  const effectiveLocale = locale ?? article.source_locale;
1955
2121
  const { translation } = await helpCenterGet(subdomain, token, `/articles/${article_id}/translations/${effectiveLocale}`);
1956
- const { translations } = await helpCenterGet(subdomain, token, `/articles/${article_id}/translations`);
2122
+ const translations = await listTranslations(subdomain, token, article_id);
1957
2123
  const sections = parseSections(translation.body);
1958
2124
  const outlineLines = sections.length ? sections.map((s) => `- [${s.index}] ${s.headingTag ? `${s.headingTag}: ` : ""}${s.heading} (${s.wordCount} words)`).join("\n") : "_(no sections detected)_";
1959
2125
  const translationsList = translations.map((t) => `- ${t.locale}${t.outdated ? " (outdated)" : ""}`).join("\n");
@@ -2048,7 +2214,7 @@ const createHelpCenterTools = (ctx) => {
2048
2214
  namespace: "help_center",
2049
2215
  readOnly: true,
2050
2216
  title: "Compare Article Translations",
2051
- description: "Compare section structure between two locales of the same article, matched by index. Returns a compact table (one row per section) with status: \"ok\" (both present, source/target word count ratio within 25%), \"different\" (word count ratio diverges by more than 25% size signal only, NOT a semantic divergence: two locales may legitimately differ in verbosity) or \"missing\" (section absent in target). Useful to spot structurally stale or missing sections; do not interpret \"different\" as an edit regression on its own.",
2217
+ description: "Compare two locales of the same article to decide whether the target translation needs work, reporting independent signals instead of one ambiguous verdict. (1) Header a \"Freshness\" verdict for the target, derived from the two translations' updated_at timestamps: if the source was edited after the target it is \"likely behind, review recommended\" (with the day gap), otherwise \"up to date\". This is the primary staleness signal and is always available. (2) Zendesk's own per-translation \"outdated\" flag for the target (\"yes\"/\"no\"/\"unknown\"), shown as a secondary overlay: it is only set through Guide's native \"mark out of date\" workflow and NOT by API edits, so a \"no\" does not by itself mean current — prefer Freshness. (3) A global structure check (section count and heading-tag sequence); on mismatch the header warns the per-index rows may be misaligned. (4) A per-section table matched by index, status \"ok\" (present in both), \"missing\" (present in source, absent in target) or \"extra\" (present in target, absent in source). (5) Per-section source/target word counts, INFORMATIONAL ONLY: a length difference between languages is normal and is deliberately NOT flagged as a divergence — do not read a word-count gap as an edit regression or staleness. Read-only; performs three Help Center GET calls (both translations plus the translations list for the outdated flag).",
2052
2218
  inputSchema: z.object({
2053
2219
  article_id: z.number().int().describe(ARTICLE_ID_DESC),
2054
2220
  source_locale: z.string().describe("Reference locale to diff against, e.g. \"en-us\". Usually the article source_locale (from get_article)."),
@@ -2063,10 +2229,32 @@ const createHelpCenterTools = (ctx) => {
2063
2229
  handler: async (params) => {
2064
2230
  const { article_id, source_locale, target_locale } = params;
2065
2231
  const token = await getToken();
2066
- const [sourceRes, targetRes] = await Promise.all([helpCenterGet(subdomain, token, `/articles/${article_id}/translations/${source_locale}`), helpCenterGet(subdomain, token, `/articles/${article_id}/translations/${target_locale}`)]);
2232
+ const [sourceRes, targetRes, translations] = await Promise.all([
2233
+ helpCenterGet(subdomain, token, `/articles/${article_id}/translations/${source_locale}`),
2234
+ helpCenterGet(subdomain, token, `/articles/${article_id}/translations/${target_locale}`),
2235
+ listTranslations(subdomain, token, article_id)
2236
+ ]);
2067
2237
  const sourceSections = parseSections(sourceRes.translation.body);
2068
2238
  const targetSections = parseSections(targetRes.translation.body);
2069
2239
  const maxLen = Math.max(sourceSections.length, targetSections.length);
2240
+ const sourceUpdated = sourceRes.translation.updated_at;
2241
+ const targetUpdated = targetRes.translation.updated_at;
2242
+ const srcMs = Date.parse(sourceUpdated);
2243
+ const tgtMs = Date.parse(targetUpdated);
2244
+ const comparable = Number.isFinite(srcMs) && Number.isFinite(tgtMs);
2245
+ let freshnessLine;
2246
+ if (comparable && srcMs > tgtMs) {
2247
+ const days = Math.floor((srcMs - tgtMs) / 864e5);
2248
+ freshnessLine = `- **Freshness (target ${target_locale})**: source was edited ${days >= 1 ? `${days} day(s)` : "less than a day"} after this translation → likely behind, review recommended.`;
2249
+ } else if (comparable) freshnessLine = `- **Freshness (target ${target_locale})**: up to date (source has not been edited since this translation).`;
2250
+ else freshnessLine = `- **Freshness (target ${target_locale})**: unknown (could not compare edit timestamps).`;
2251
+ const targetLocaleKey = target_locale.toLowerCase();
2252
+ const targetListEntry = translations.find((t) => t.locale.toLowerCase() === targetLocaleKey);
2253
+ const outdated = targetListEntry?.outdated === void 0 ? "unknown" : targetListEntry.outdated ? "yes" : "no";
2254
+ const outdatedLine = outdated === "yes" ? `- **Zendesk outdated flag (target ${target_locale})**: yes — explicitly marked out of date in Guide.` : outdated === "no" ? `- **Zendesk outdated flag (target ${target_locale})**: no (only set via Guide's own edit workflow; "no" does not by itself mean current — rely on Freshness above).` : `- **Zendesk outdated flag (target ${target_locale})**: unknown.`;
2255
+ const sourceTags = sourceSections.map((s) => s.headingTag).join(",");
2256
+ const targetTags = targetSections.map((s) => s.headingTag).join(",");
2257
+ const structureLine = sourceSections.length === targetSections.length && sourceTags === targetTags ? `- **Structure**: ${sourceSections.length} sections in both locales — aligned.` : `- **Structure**: ${sourceSections.length} source vs ${targetSections.length} target sections — MISMATCH; the per-index rows below may be misaligned.`;
2070
2258
  const rows = [];
2071
2259
  rows.push(`| Idx | Heading | Status | Source words | Target words |`);
2072
2260
  rows.push(`| --- | --- | --- | --- | --- |`);
@@ -2078,11 +2266,8 @@ const createHelpCenterTools = (ctx) => {
2078
2266
  const targetWords = tgt?.wordCount ?? 0;
2079
2267
  let status;
2080
2268
  if (!tgt) status = "missing";
2081
- else if (!src) status = "different";
2082
- else {
2083
- const denom = Math.max(sourceWords, 1);
2084
- status = Math.abs(sourceWords - targetWords) / denom > .25 ? "different" : "ok";
2085
- }
2269
+ else if (!src) status = "extra";
2270
+ else status = "ok";
2086
2271
  rows.push(`| ${i} | ${heading} | ${status} | ${sourceWords} | ${targetWords} |`);
2087
2272
  }
2088
2273
  return { content: [{
@@ -2090,6 +2275,14 @@ const createHelpCenterTools = (ctx) => {
2090
2275
  text: [
2091
2276
  `# Translation diff — Article #${article_id} (${source_locale} → ${target_locale})`,
2092
2277
  "",
2278
+ freshnessLine,
2279
+ outdatedLine,
2280
+ structureLine,
2281
+ `- **Updated**: source ${sourceUpdated} | target ${targetUpdated}`,
2282
+ `- **Target draft**: ${targetRes.translation.draft ? "yes" : "no"}`,
2283
+ "",
2284
+ "_Word counts are informational: a length difference between languages is normal, not a divergence._",
2285
+ "",
2093
2286
  ...rows
2094
2287
  ].join("\n")
2095
2288
  }] };