@fruggr/zendesk-mcp-server 2.12.2 → 2.13.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",
@@ -1411,8 +1487,24 @@ const largeArticleHint = (body, sectionCount) => {
1411
1487
  ""
1412
1488
  ].join("\n");
1413
1489
  };
1490
+ 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
1491
  const createHelpCenterTools = (ctx) => {
1415
1492
  const { subdomain, getToken } = ctx;
1493
+ const fetchSectionOrder = async (sectionId, locale, token) => {
1494
+ const order = [];
1495
+ let cursor;
1496
+ do {
1497
+ const response = await helpCenterGet(subdomain, token, `/${locale}/sections/${sectionId}/articles`, buildCursorParams(100, cursor));
1498
+ const articles = response.articles ?? [];
1499
+ for (const article of articles) order.push({
1500
+ id: article.id,
1501
+ position: article.position
1502
+ });
1503
+ const meta = extractPaginationMeta(response, articles.length);
1504
+ cursor = meta.has_more && meta.after_cursor ? meta.after_cursor : void 0;
1505
+ } while (cursor);
1506
+ return order;
1507
+ };
1416
1508
  return [
1417
1509
  {
1418
1510
  name: "search_articles",
@@ -1769,6 +1861,81 @@ const createHelpCenterTools = (ctx) => {
1769
1861
  }] };
1770
1862
  }
1771
1863
  },
1864
+ {
1865
+ name: "reorder_article",
1866
+ namespace: "help_center",
1867
+ readOnly: false,
1868
+ title: "Reorder Help Center Article",
1869
+ 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.",
1870
+ inputSchema: z.object({
1871
+ 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."),
1872
+ target: z.enum([
1873
+ "top",
1874
+ "bottom",
1875
+ "before",
1876
+ "after"
1877
+ ]).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."),
1878
+ 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\"."),
1879
+ 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."),
1880
+ 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.")
1881
+ }),
1882
+ annotations: {
1883
+ readOnlyHint: false,
1884
+ destructiveHint: true,
1885
+ idempotentHint: true,
1886
+ openWorldHint: true
1887
+ },
1888
+ handler: async (params) => {
1889
+ const { article_id, target, reference_article_id, normalize = false, confirm = false } = params;
1890
+ const needsReference = target === "before" || target === "after";
1891
+ if (needsReference && reference_article_id === void 0) throw new Error(`target "${target}" requires reference_article_id (the article to move ${target}).`);
1892
+ 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").`);
1893
+ if (reference_article_id !== void 0 && reference_article_id === article_id) throw new Error("reference_article_id must differ from article_id.");
1894
+ const token = await getToken();
1895
+ const { article } = await helpCenterGet(subdomain, token, `/articles/${article_id}`);
1896
+ const sectionId = article.section_id;
1897
+ const locale = article.source_locale;
1898
+ const effective = await fetchSectionOrder(sectionId, locale, token);
1899
+ if (needsReference && !effective.some((a) => a.id === reference_article_id)) {
1900
+ let detail = "was not found";
1901
+ try {
1902
+ const { article: ref } = await helpCenterGet(subdomain, token, `/articles/${reference_article_id}`);
1903
+ detail = `is in section #${ref.section_id}, not section #${sectionId}`;
1904
+ } catch {}
1905
+ throw new Error(`Reference article #${reference_article_id} ${detail}. It must be in the same section (#${sectionId}) as article #${article_id}.`);
1906
+ }
1907
+ const targetLabel = needsReference ? `${target} article #${reference_article_id}` : target;
1908
+ const writes = computePositionWrites(arrangeDesiredOrder(effective, article_id, target, reference_article_id), article_id, normalize);
1909
+ if (writes.length === 0) return { content: [{
1910
+ type: "text",
1911
+ text: `Article #${article_id} is already positioned ${targetLabel} in section #${sectionId}. No changes made.`
1912
+ }] };
1913
+ if (hasPositionInversion(effective)) return { content: [{
1914
+ type: "text",
1915
+ text: autoSortNotice(sectionId)
1916
+ }] };
1917
+ if (writes.length > REORDER_CONFIRM_THRESHOLD && confirm !== true) return { content: [{
1918
+ type: "text",
1919
+ 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.`
1920
+ }] };
1921
+ let applied = 0;
1922
+ for (const write of writes) try {
1923
+ await helpCenterPut(subdomain, token, `/articles/${write.id}`, { article: { position: write.position } });
1924
+ applied += 1;
1925
+ } catch (error) {
1926
+ const reason = error instanceof Error ? error.message : String(error);
1927
+ 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 });
1928
+ }
1929
+ if (!isPlacedAsRequested(await fetchSectionOrder(sectionId, locale, token), article_id, target, reference_article_id)) return { content: [{
1930
+ type: "text",
1931
+ text: autoSortNotice(sectionId, applied)
1932
+ }] };
1933
+ return { content: [{
1934
+ type: "text",
1935
+ text: `Article #${article_id} moved to ${targetLabel} in section #${sectionId} (${applied} article${applied === 1 ? "" : "s"} repositioned).`
1936
+ }] };
1937
+ }
1938
+ },
1772
1939
  {
1773
1940
  name: "archive_article",
1774
1941
  namespace: "help_center",