@fruggr/zendesk-mcp-server 2.8.0 → 2.10.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/README.md CHANGED
@@ -9,10 +9,10 @@
9
9
  [![semantic-release](https://img.shields.io/badge/semantic--release-e10079?logo=semantic-release&logoColor=white)](https://github.com/semantic-release/semantic-release)
10
10
 
11
11
  **Bring Zendesk deep into your AI assistant.** A
12
- [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for a
13
- two-way integration: find answers in the Help Center, **draft, update and
14
- translate** articles (keeping languages in sync), and **manage Support tickets**
15
- end to end — comments, triage and image attachments — all in plain language,
12
+ [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server: find
13
+ answers in the Help Center, **draft, update and translate** articles (keeping
14
+ languages in sync), and **manage Support tickets** end to end — comments, triage
15
+ and image attachments — all in plain language,
16
16
  **without switching apps**.
17
17
 
18
18
  Think of it as the [Zendesk agent for Microsoft 365 Copilot](https://support.zendesk.com/hc/en-us/articles/9958331458458-Using-the-Zendesk-agent-in-Microsoft-365-Copilot),
package/dist/index.js CHANGED
@@ -261,14 +261,15 @@ const startBrowserAuth = (config, logger = silentLogger) => {
261
261
  });
262
262
  const port = callbackServer.address().port;
263
263
  const redirectUri = `http://localhost:${port}/callback`;
264
- const authUrl = `${authorizeBase}?${new URLSearchParams({
264
+ const params = new URLSearchParams({
265
265
  response_type: "code",
266
266
  client_id: oauthClientId,
267
267
  redirect_uri: redirectUri,
268
268
  scope: "read write",
269
269
  code_challenge: codeChallenge,
270
270
  code_challenge_method: "S256"
271
- }).toString()}`;
271
+ });
272
+ const authUrl = `${authorizeBase}?${params.toString()}`;
272
273
  logger.debug("oauth_callback_listening", {
273
274
  port,
274
275
  redirectUri
@@ -533,7 +534,8 @@ const createTokenStore = (config, logger = silentLogger) => {
533
534
  if (refreshed) return refreshed;
534
535
  }
535
536
  if (!starting) starting = beginAuth();
536
- throw createAuthRequiredError(authorizeUrl ?? await starting);
537
+ const url = authorizeUrl ?? await starting;
538
+ throw createAuthRequiredError(url);
537
539
  };
538
540
  const invalidate = () => {
539
541
  if (token?.refreshToken) {
@@ -746,37 +748,44 @@ const executeRequest = async (url, token, options = {}) => {
746
748
  return response.json();
747
749
  };
748
750
  const zendeskGet = (subdomain, token, path, params) => {
749
- return executeRequest(buildUrl(getBaseUrl(subdomain), path, params), token);
751
+ const url = buildUrl(getBaseUrl(subdomain), path, params);
752
+ return executeRequest(url, token);
750
753
  };
751
754
  const zendeskPost = (subdomain, token, path, body) => {
752
- return executeRequest(buildUrl(getBaseUrl(subdomain), path), token, {
755
+ const url = buildUrl(getBaseUrl(subdomain), path);
756
+ return executeRequest(url, token, {
753
757
  method: "POST",
754
758
  body
755
759
  });
756
760
  };
757
761
  const zendeskPut = (subdomain, token, path, body) => {
758
- return executeRequest(buildUrl(getBaseUrl(subdomain), path), token, {
762
+ const url = buildUrl(getBaseUrl(subdomain), path);
763
+ return executeRequest(url, token, {
759
764
  method: "PUT",
760
765
  body
761
766
  });
762
767
  };
763
768
  const helpCenterGet = (subdomain, token, path, params) => {
764
- return executeRequest(buildUrl(getHelpCenterBaseUrl(subdomain), path, params), token);
769
+ const url = buildUrl(getHelpCenterBaseUrl(subdomain), path, params);
770
+ return executeRequest(url, token);
765
771
  };
766
772
  const helpCenterPost = (subdomain, token, path, body) => {
767
- return executeRequest(buildUrl(getHelpCenterBaseUrl(subdomain), path), token, {
773
+ const url = buildUrl(getHelpCenterBaseUrl(subdomain), path);
774
+ return executeRequest(url, token, {
768
775
  method: "POST",
769
776
  body
770
777
  });
771
778
  };
772
779
  const helpCenterPut = (subdomain, token, path, body) => {
773
- return executeRequest(buildUrl(getHelpCenterBaseUrl(subdomain), path), token, {
780
+ const url = buildUrl(getHelpCenterBaseUrl(subdomain), path);
781
+ return executeRequest(url, token, {
774
782
  method: "PUT",
775
783
  body
776
784
  });
777
785
  };
778
786
  const helpCenterDelete = (subdomain, token, path) => {
779
- return executeRequest(buildUrl(getHelpCenterBaseUrl(subdomain), path), token, { method: "DELETE" });
787
+ const url = buildUrl(getHelpCenterBaseUrl(subdomain), path);
788
+ return executeRequest(url, token, { method: "DELETE" });
780
789
  };
781
790
  const fetchZendeskBinary = async (subdomain, token, contentUrl) => {
782
791
  const expectedHost = `${subdomain}.zendesk.com`;
@@ -888,6 +897,36 @@ const formatSlaPolicy = (policy) => {
888
897
  ...targets
889
898
  ].filter(Boolean).join("\n");
890
899
  };
900
+ const formatTicketField = (field) => {
901
+ const flags = [field.active ? "active" : "inactive", field.required ? "required" : "optional"].join(", ");
902
+ const options = field.custom_field_options ?? field.system_field_options ?? [];
903
+ return [
904
+ `## ${field.title} (id ${field.id})`,
905
+ `- **Type**: ${field.type} | **${flags}**`,
906
+ field.description ? `- **Description**: ${field.description}` : "",
907
+ field.tag ? `- **Tag**: ${field.tag}` : "",
908
+ options.length > 0 ? "- **Options** (name → value):" : "",
909
+ ...options.map((o) => ` - ${o.name} → ${o.value}`)
910
+ ].filter(Boolean).join("\n");
911
+ };
912
+ const formatFieldValue = (value) => Array.isArray(value) ? value.map(formatFieldValue).join(", ") : formatConditionValue(value);
913
+ const MACRO_VALUE_PREVIEW = 120;
914
+ const formatMacroActionValue = (value) => {
915
+ const oneLine = formatFieldValue(value).replace(/\s+/g, " ").trim();
916
+ return oneLine.length > MACRO_VALUE_PREVIEW ? `${oneLine.slice(0, MACRO_VALUE_PREVIEW)}…` : oneLine;
917
+ };
918
+ const formatMacroAction = (action) => ` - ${action.field} → ${formatMacroActionValue(action.value)}`;
919
+ const formatMacro = (macro) => {
920
+ const scope = macro.restriction?.type ? "restricted" : "shared";
921
+ const actions = macro.actions ?? [];
922
+ return [
923
+ `## ${macro.title} (id ${macro.id})`,
924
+ `- **${macro.active ? "active" : "inactive"}** | **Scope**: ${scope}`,
925
+ macro.description ? `- **Description**: ${macro.description}` : "",
926
+ actions.length > 0 ? "- **Actions**:" : "- **Actions**: none",
927
+ ...actions.map(formatMacroAction)
928
+ ].filter(Boolean).join("\n");
929
+ };
891
930
  const minutesUntil = (iso) => {
892
931
  const t = Date.parse(iso);
893
932
  return Number.isNaN(t) ? null : Math.round((t - Date.now()) / 6e4);
@@ -967,7 +1006,8 @@ const formatLabel = (label) => `- **${label.name}** (${label.id})`;
967
1006
  const formatUserSegment = (segment) => `- **${segment.name}** (${segment.id}) — ${segment.user_type}${segment.built_in ? " — Built-in" : ""}`;
968
1007
  const formatAttachment = (attachment) => `- **${attachment.file_name}** (${attachment.id}) — ${attachment.content_type} — ${attachment.size} bytes`;
969
1008
  const formatList = (items, formatter, meta) => {
970
- return truncateIfNeeded([meta ? formatPagination(meta) : "", items.map(formatter).join("\n\n")].filter(Boolean).join("\n\n"));
1009
+ const text = [meta ? formatPagination(meta) : "", items.map(formatter).join("\n\n")].filter(Boolean).join("\n\n");
1010
+ return truncateIfNeeded(text);
971
1011
  };
972
1012
  //#endregion
973
1013
  //#region src/utils/pagination.ts
@@ -988,6 +1028,11 @@ const extractPaginationMeta = (response, itemCount) => ({
988
1028
  after_cursor: response.meta?.after_cursor ?? null,
989
1029
  count: response.count ?? itemCount
990
1030
  });
1031
+ const extractOffsetPaginationMeta = (response, itemCount, perPage, page) => response.count != null ? extractSearchPaginationMeta(response, perPage, page) : {
1032
+ count: itemCount,
1033
+ has_more: false,
1034
+ after_cursor: null
1035
+ };
991
1036
  const extractSearchPaginationMeta = (response, perPage, page) => {
992
1037
  const count = response.count ?? 0;
993
1038
  const has_more = count > page * perPage;
@@ -1093,7 +1138,8 @@ const createTopologyProvider = (getToken, subdomain, onUnauthorized) => {
1093
1138
  const now = Date.now();
1094
1139
  if (cached && now - cached.at < 3e5) return cached.promise;
1095
1140
  const promise = (async () => {
1096
- return formatTopology(await fetchTopology(subdomain, await getToken()));
1141
+ const token = await getToken();
1142
+ return formatTopology(await fetchTopology(subdomain, token));
1097
1143
  })().catch((err) => {
1098
1144
  cached = void 0;
1099
1145
  if (onUnauthorized && err instanceof ZendeskApiError && err.status === 401) onUnauthorized();
@@ -1285,7 +1331,8 @@ const createHelpCenterTools = (ctx) => {
1285
1331
  handler: async (params) => {
1286
1332
  const { article_id, locale } = params;
1287
1333
  const token = await getToken();
1288
- const { article } = await helpCenterGet(subdomain, token, locale ? `/${locale}/articles/${article_id}` : `/articles/${article_id}`);
1334
+ const path = locale ? `/${locale}/articles/${article_id}` : `/articles/${article_id}`;
1335
+ const { article } = await helpCenterGet(subdomain, token, path);
1289
1336
  const { translations } = await helpCenterGet(subdomain, token, `/articles/${article_id}/translations`);
1290
1337
  return { content: [{
1291
1338
  type: "text",
@@ -1312,7 +1359,9 @@ const createHelpCenterTools = (ctx) => {
1312
1359
  },
1313
1360
  handler: async (params) => {
1314
1361
  const { locale, page_size, cursor } = params;
1315
- const response = await helpCenterGet(subdomain, await getToken(), locale ? `/${locale}/categories` : "/categories", buildCursorParams(page_size, cursor));
1362
+ const token = await getToken();
1363
+ const path = locale ? `/${locale}/categories` : "/categories";
1364
+ const response = await helpCenterGet(subdomain, token, path, buildCursorParams(page_size, cursor));
1316
1365
  const categories = response.categories ?? [];
1317
1366
  return { content: [{
1318
1367
  type: "text",
@@ -1340,7 +1389,9 @@ const createHelpCenterTools = (ctx) => {
1340
1389
  },
1341
1390
  handler: async (params) => {
1342
1391
  const { category_id, locale, page_size, cursor } = params;
1343
- const response = await helpCenterGet(subdomain, await getToken(), category_id && locale ? `/${locale}/categories/${category_id}/sections` : category_id ? `/categories/${category_id}/sections` : locale ? `/${locale}/sections` : "/sections", buildCursorParams(page_size, cursor));
1392
+ const token = await getToken();
1393
+ const path = category_id && locale ? `/${locale}/categories/${category_id}/sections` : category_id ? `/categories/${category_id}/sections` : locale ? `/${locale}/sections` : "/sections";
1394
+ const response = await helpCenterGet(subdomain, token, path, buildCursorParams(page_size, cursor));
1344
1395
  const sections = response.sections ?? [];
1345
1396
  return { content: [{
1346
1397
  type: "text",
@@ -1377,7 +1428,8 @@ const createHelpCenterTools = (ctx) => {
1377
1428
  handler: async (params) => {
1378
1429
  const { section_id, locale, page_size, cursor, sort_by, sort_order, include_translations } = params;
1379
1430
  const token = await getToken();
1380
- const response = await helpCenterGet(subdomain, token, section_id && locale ? `/${locale}/sections/${section_id}/articles` : section_id ? `/sections/${section_id}/articles` : locale ? `/${locale}/articles` : "/articles", {
1431
+ const path = section_id && locale ? `/${locale}/sections/${section_id}/articles` : section_id ? `/sections/${section_id}/articles` : locale ? `/${locale}/articles` : "/articles";
1432
+ const response = await helpCenterGet(subdomain, token, path, {
1381
1433
  ...buildCursorParams(page_size, cursor),
1382
1434
  sort_by,
1383
1435
  sort_order
@@ -1414,7 +1466,8 @@ const createHelpCenterTools = (ctx) => {
1414
1466
  },
1415
1467
  handler: async (params) => {
1416
1468
  const { article_id } = params;
1417
- const { translations } = await helpCenterGet(subdomain, await getToken(), `/articles/${article_id}/translations`);
1469
+ const token = await getToken();
1470
+ const { translations } = await helpCenterGet(subdomain, token, `/articles/${article_id}/translations`);
1418
1471
  return { content: [{
1419
1472
  type: "text",
1420
1473
  text: formatList(translations, formatTranslationSummary)
@@ -1442,7 +1495,8 @@ const createHelpCenterTools = (ctx) => {
1442
1495
  },
1443
1496
  handler: async (params) => {
1444
1497
  const { article_id, locale, title, body, draft } = params;
1445
- const { translation } = await helpCenterPost(subdomain, await getToken(), `/articles/${article_id}/translations`, { translation: {
1498
+ const token = await getToken();
1499
+ const { translation } = await helpCenterPost(subdomain, token, `/articles/${article_id}/translations`, { translation: {
1446
1500
  locale,
1447
1501
  title,
1448
1502
  body,
@@ -1475,7 +1529,8 @@ const createHelpCenterTools = (ctx) => {
1475
1529
  },
1476
1530
  handler: async (params) => {
1477
1531
  const { article_id, locale, ...updates } = params;
1478
- const { translation } = await helpCenterPut(subdomain, await getToken(), `/articles/${article_id}/translations/${locale}`, { translation: updates });
1532
+ const token = await getToken();
1533
+ const { translation } = await helpCenterPut(subdomain, token, `/articles/${article_id}/translations/${locale}`, { translation: updates });
1479
1534
  return { content: [{
1480
1535
  type: "text",
1481
1536
  text: `Translation updated for article #${article_id} in "${locale}".\n\n${formatTranslation(translation)}`
@@ -1496,9 +1551,10 @@ const createHelpCenterTools = (ctx) => {
1496
1551
  openWorldHint: true
1497
1552
  },
1498
1553
  handler: async () => {
1554
+ const token = await getToken();
1499
1555
  return { content: [{
1500
1556
  type: "text",
1501
- text: formatList((await zendeskGet(subdomain, await getToken(), "/guide/permission_groups")).permission_groups ?? [], formatPermissionGroup)
1557
+ text: formatList((await zendeskGet(subdomain, token, "/guide/permission_groups")).permission_groups ?? [], formatPermissionGroup)
1502
1558
  }] };
1503
1559
  }
1504
1560
  },
@@ -1529,7 +1585,8 @@ const createHelpCenterTools = (ctx) => {
1529
1585
  },
1530
1586
  handler: async (params) => {
1531
1587
  const { section_id, ...articleData } = params;
1532
- const { article } = await helpCenterPost(subdomain, await getToken(), `/sections/${section_id}/articles`, { article: articleData });
1588
+ const token = await getToken();
1589
+ const { article } = await helpCenterPost(subdomain, token, `/sections/${section_id}/articles`, { article: articleData });
1533
1590
  return { content: [{
1534
1591
  type: "text",
1535
1592
  text: `Article #${article.id} created.\n\n${formatArticle(article)}`
@@ -1562,7 +1619,8 @@ const createHelpCenterTools = (ctx) => {
1562
1619
  },
1563
1620
  handler: async (params) => {
1564
1621
  const { article_id, ...updates } = params;
1565
- const { article } = await helpCenterPut(subdomain, await getToken(), `/articles/${article_id}`, { article: updates });
1622
+ const token = await getToken();
1623
+ const { article } = await helpCenterPut(subdomain, token, `/articles/${article_id}`, { article: updates });
1566
1624
  return { content: [{
1567
1625
  type: "text",
1568
1626
  text: `Article #${article.id} updated.\n\n${formatArticle(article)}`
@@ -1588,7 +1646,8 @@ const createHelpCenterTools = (ctx) => {
1588
1646
  handler: async (params) => {
1589
1647
  const { article_id, confirm } = params;
1590
1648
  if (confirm !== true) throw new Error("Archiving is guarded: pass confirm: true to archive (soft-delete) this article. No changes were made.");
1591
- await helpCenterDelete(subdomain, await getToken(), `/articles/${article_id}`);
1649
+ const token = await getToken();
1650
+ await helpCenterDelete(subdomain, token, `/articles/${article_id}`);
1592
1651
  return { content: [{
1593
1652
  type: "text",
1594
1653
  text: `Article #${article_id} archived (soft-deleted). It is removed from the Help Center; restore it from the Guide admin UI if needed.`
@@ -1646,7 +1705,8 @@ const createHelpCenterTools = (ctx) => {
1646
1705
  },
1647
1706
  handler: async (params) => {
1648
1707
  const { name } = params;
1649
- const { content_tag } = await zendeskPost(subdomain, await getToken(), "/guide/content_tags", { content_tag: { name } });
1708
+ const token = await getToken();
1709
+ const { content_tag } = await zendeskPost(subdomain, token, "/guide/content_tags", { content_tag: { name } });
1650
1710
  return { content: [{
1651
1711
  type: "text",
1652
1712
  text: `Content tag created.\n\n${formatContentTag(content_tag)}`
@@ -1667,9 +1727,10 @@ const createHelpCenterTools = (ctx) => {
1667
1727
  openWorldHint: true
1668
1728
  },
1669
1729
  handler: async () => {
1730
+ const token = await getToken();
1670
1731
  return { content: [{
1671
1732
  type: "text",
1672
- text: formatList((await helpCenterGet(subdomain, await getToken(), "/articles/labels")).labels ?? [], formatLabel)
1733
+ text: formatList((await helpCenterGet(subdomain, token, "/articles/labels")).labels ?? [], formatLabel)
1673
1734
  }] };
1674
1735
  }
1675
1736
  },
@@ -1687,9 +1748,10 @@ const createHelpCenterTools = (ctx) => {
1687
1748
  openWorldHint: true
1688
1749
  },
1689
1750
  handler: async () => {
1751
+ const token = await getToken();
1690
1752
  return { content: [{
1691
1753
  type: "text",
1692
- text: formatList((await helpCenterGet(subdomain, await getToken(), "/user_segments")).user_segments ?? [], formatUserSegment)
1754
+ text: formatList((await helpCenterGet(subdomain, token, "/user_segments")).user_segments ?? [], formatUserSegment)
1693
1755
  }] };
1694
1756
  }
1695
1757
  },
@@ -1708,7 +1770,8 @@ const createHelpCenterTools = (ctx) => {
1708
1770
  },
1709
1771
  handler: async (params) => {
1710
1772
  const { article_id } = params;
1711
- const attachments = (await helpCenterGet(subdomain, await getToken(), `/articles/${article_id}/attachments`)).article_attachments ?? [];
1773
+ const token = await getToken();
1774
+ const attachments = (await helpCenterGet(subdomain, token, `/articles/${article_id}/attachments`)).article_attachments ?? [];
1712
1775
  if (attachments.length === 0) return { content: [{
1713
1776
  type: "text",
1714
1777
  text: `No attachments found on article #${article_id}.`
@@ -1780,7 +1843,8 @@ const createHelpCenterTools = (ctx) => {
1780
1843
  },
1781
1844
  handler: async (params) => {
1782
1845
  const { article_id, locale, section_index, format } = params;
1783
- const { translation } = await helpCenterGet(subdomain, await getToken(), `/articles/${article_id}/translations/${locale}`);
1846
+ const token = await getToken();
1847
+ const { translation } = await helpCenterGet(subdomain, token, `/articles/${article_id}/translations/${locale}`);
1784
1848
  const sections = parseSections(translation.body);
1785
1849
  const section = sections[section_index];
1786
1850
  if (!section) throw new Error(`Section index ${section_index} not found. Article has ${sections.length} section(s) (0-${Math.max(0, sections.length - 1)}).`);
@@ -1952,7 +2016,8 @@ const createSearchTools = (ctx) => {
1952
2016
  },
1953
2017
  handler: async (params) => {
1954
2018
  const { query, per_page, page } = params;
1955
- const response = await zendeskGet(subdomain, await getToken(), "/search", {
2019
+ const token = await getToken();
2020
+ const response = await zendeskGet(subdomain, token, "/search", {
1956
2021
  query,
1957
2022
  ...buildOffsetParams(per_page, page)
1958
2023
  });
@@ -2049,6 +2114,71 @@ const fetchTicketSla = async (subdomain, token, ticket) => {
2049
2114
  return;
2050
2115
  }
2051
2116
  };
2117
+ const DIFF_SKIP_KEYS = /* @__PURE__ */ new Set([
2118
+ "comment",
2119
+ "fields",
2120
+ "custom_fields",
2121
+ "id",
2122
+ "url",
2123
+ "created_at",
2124
+ "updated_at",
2125
+ "generated_timestamp",
2126
+ "encoded_id"
2127
+ ]);
2128
+ const valuesEqual = (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b);
2129
+ const shownValue = (v) => {
2130
+ const s = formatFieldValue(v);
2131
+ return s === "" ? "(empty)" : s;
2132
+ };
2133
+ const diffLine = (label, before, after) => {
2134
+ const b = shownValue(before);
2135
+ const a = shownValue(after);
2136
+ return b === a ? null : `- **${label}**: ${b} → ${a}`;
2137
+ };
2138
+ const formatTagDiff = (before, after) => {
2139
+ const b = new Set(Array.isArray(before) ? before.map(String) : []);
2140
+ const a = new Set(Array.isArray(after) ? after.map(String) : []);
2141
+ const added = [...a].filter((t) => !b.has(t)).map((t) => `+${t}`);
2142
+ const removed = [...b].filter((t) => !a.has(t)).map((t) => `-${t}`);
2143
+ return added.length + removed.length === 0 ? null : `- **tags**: ${[...added, ...removed].join(", ")}`;
2144
+ };
2145
+ const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
2146
+ const after = result?.ticket ?? {};
2147
+ const beforeObj = before ?? {};
2148
+ const comment = after.comment ?? result?.comment;
2149
+ const changes = [];
2150
+ for (const [key, afterVal] of Object.entries(after)) {
2151
+ if (DIFF_SKIP_KEYS.has(key)) continue;
2152
+ const beforeVal = beforeObj[key];
2153
+ if (valuesEqual(beforeVal, afterVal)) continue;
2154
+ if (key === "tags") {
2155
+ const tagLine = formatTagDiff(beforeVal, afterVal);
2156
+ if (tagLine) changes.push(tagLine);
2157
+ continue;
2158
+ }
2159
+ if (afterVal !== null && typeof afterVal === "object" && !Array.isArray(afterVal)) continue;
2160
+ const line = diffLine(key, beforeVal, afterVal);
2161
+ if (line) changes.push(line);
2162
+ }
2163
+ const afterFields = [after.fields ?? after.custom_fields ?? []].flat();
2164
+ const beforeById = new Map((before?.custom_fields ?? []).map((f) => [f.id, f.value]));
2165
+ for (const f of afterFields) {
2166
+ const line = diffLine(`custom field ${f.id}`, beforeById.get(f.id), f.value);
2167
+ if (line) changes.push(line);
2168
+ }
2169
+ const lines = [
2170
+ `# Macro #${macroId} preview on ticket #${ticketId} (diff — nothing saved yet)`,
2171
+ "",
2172
+ "## Field changes",
2173
+ ...changes.length > 0 ? changes : ["- none"]
2174
+ ];
2175
+ if (comment?.body) {
2176
+ const visibility = comment.public === false ? "internal note" : "public comment";
2177
+ lines.push("", `## Reply (${visibility})`, "", comment.body);
2178
+ } else lines.push("", "## Reply", "- none");
2179
+ lines.push("", "## To apply these changes", "Nothing has been committed. Persist the field changes with `update_ticket` (or `manage_tags` for incremental tag edits), and post the reply with `add_public_comment` (public) or `add_private_note` (internal). Edit the reply text first if needed.");
2180
+ return lines.join("\n");
2181
+ };
2052
2182
  const createTicketTools = (ctx) => {
2053
2183
  const { subdomain, getToken } = ctx;
2054
2184
  const attachmentSchema = z.object({
@@ -2156,7 +2286,8 @@ const createTicketTools = (ctx) => {
2156
2286
  },
2157
2287
  handler: async (params) => {
2158
2288
  const { query, per_page, page } = params;
2159
- const response = await zendeskGet(subdomain, await getToken(), "/search", {
2289
+ const token = await getToken();
2290
+ const response = await zendeskGet(subdomain, token, "/search", {
2160
2291
  query: `type:ticket ${query}`,
2161
2292
  include: "tickets(slas)",
2162
2293
  ...buildOffsetParams(per_page, page)
@@ -2173,7 +2304,7 @@ const createTicketTools = (ctx) => {
2173
2304
  namespace: "tickets",
2174
2305
  readOnly: false,
2175
2306
  title: "Create Zendesk Ticket",
2176
- description: "Create a new Zendesk support ticket with subject, description, and optional priority/type/assignee/tags. The description becomes the first public comment of the ticket, and the new ticket id is returned. After creation, use update_ticket to change status or assignee, add_public_comment or add_private_note to reply, and manage_tags to adjust tags. Look up valid assignee_id / group_id and custom field ids via search_users or your Zendesk admin settings.",
2307
+ description: "Create a new Zendesk support ticket with subject, description, and optional priority/type/assignee/tags. The description becomes the first public comment of the ticket, and the new ticket id is returned. After creation, use update_ticket to change status or assignee, add_public_comment or add_private_note to reply, and manage_tags to adjust tags. Look up valid assignee_id / group_id and custom field ids via search_users or your Zendesk admin settings. Discover custom field ids and their accepted option values with list_ticket_fields.",
2177
2308
  inputSchema: z.object({
2178
2309
  subject: z.string().min(1).describe("Ticket subject — the short summary line shown in ticket lists and search results."),
2179
2310
  description: z.string().min(1).describe("Ticket description — the body of the request. It becomes the ticket's first public comment (visible to the requester)."),
@@ -2195,7 +2326,7 @@ const createTicketTools = (ctx) => {
2195
2326
  custom_fields: z.array(z.object({
2196
2327
  id: z.number().int(),
2197
2328
  value: z.unknown()
2198
- })).optional().describe("Custom field values as { id, value } pairs (field ids come from your Zendesk admin settings).")
2329
+ })).optional().describe("Custom field values as { id, value } pairs (field ids come from your Zendesk admin settings). Call list_ticket_fields first to discover the numeric field ids and, for dropdown/multiselect fields, the exact option values Zendesk accepts.")
2199
2330
  }),
2200
2331
  annotations: {
2201
2332
  readOnlyHint: false,
@@ -2205,7 +2336,8 @@ const createTicketTools = (ctx) => {
2205
2336
  },
2206
2337
  handler: async (params) => {
2207
2338
  const { subject, description, ...rest } = params;
2208
- const { ticket } = await zendeskPost(subdomain, await getToken(), "/tickets", { ticket: {
2339
+ const token = await getToken();
2340
+ const { ticket } = await zendeskPost(subdomain, token, "/tickets", { ticket: {
2209
2341
  subject,
2210
2342
  comment: { body: description },
2211
2343
  ...rest
@@ -2251,7 +2383,7 @@ const createTicketTools = (ctx) => {
2251
2383
  custom_fields: z.array(z.object({
2252
2384
  id: z.number().int(),
2253
2385
  value: z.unknown()
2254
- })).optional().describe("Custom field values as { id, value } pairs (field ids come from your Zendesk admin settings).")
2386
+ })).optional().describe("Custom field values as { id, value } pairs (field ids come from your Zendesk admin settings). Call list_ticket_fields first to discover the numeric field ids and, for dropdown/multiselect fields, the exact option values Zendesk accepts.")
2255
2387
  }),
2256
2388
  annotations: {
2257
2389
  readOnlyHint: false,
@@ -2261,7 +2393,8 @@ const createTicketTools = (ctx) => {
2261
2393
  },
2262
2394
  handler: async (params) => {
2263
2395
  const { ticket_id, ...updates } = params;
2264
- const { ticket } = await zendeskPut(subdomain, await getToken(), `/tickets/${ticket_id}`, { ticket: updates });
2396
+ const token = await getToken();
2397
+ const { ticket } = await zendeskPut(subdomain, token, `/tickets/${ticket_id}`, { ticket: updates });
2265
2398
  return { content: [{
2266
2399
  type: "text",
2267
2400
  text: `Ticket #${ticket.id} updated.\n\n${formatTicket(ticket)}`
@@ -2350,7 +2483,8 @@ const createTicketTools = (ctx) => {
2350
2483
  },
2351
2484
  handler: async (params) => {
2352
2485
  const { page_size, cursor } = params;
2353
- const response = await zendeskGet(subdomain, await getToken(), "/tickets", buildCursorParams(page_size, cursor));
2486
+ const token = await getToken();
2487
+ const response = await zendeskGet(subdomain, token, "/tickets", buildCursorParams(page_size, cursor));
2354
2488
  const tickets = response.tickets ?? [];
2355
2489
  return { content: [{
2356
2490
  type: "text",
@@ -2373,7 +2507,8 @@ const createTicketTools = (ctx) => {
2373
2507
  },
2374
2508
  handler: async (params) => {
2375
2509
  const { problem_id } = params;
2376
- const incidents = (await zendeskGet(subdomain, await getToken(), `/tickets/${problem_id}/incidents`)).tickets ?? [];
2510
+ const token = await getToken();
2511
+ const incidents = (await zendeskGet(subdomain, token, `/tickets/${problem_id}/incidents`)).tickets ?? [];
2377
2512
  return { content: [{
2378
2513
  type: "text",
2379
2514
  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}.`)
@@ -2444,11 +2579,87 @@ const createTicketTools = (ctx) => {
2444
2579
  const policies = response.sla_policies ?? [];
2445
2580
  return { content: [{
2446
2581
  type: "text",
2447
- text: formatList(policies, formatSlaPolicy, response.count != null ? extractSearchPaginationMeta(response, per_page, page) : {
2448
- count: policies.length,
2449
- has_more: false,
2450
- after_cursor: null
2451
- })
2582
+ text: formatList(policies, formatSlaPolicy, extractOffsetPaginationMeta(response, policies.length, per_page, page))
2583
+ }] };
2584
+ }
2585
+ },
2586
+ {
2587
+ name: "list_ticket_fields",
2588
+ namespace: "tickets",
2589
+ readOnly: true,
2590
+ title: "List Ticket Fields",
2591
+ description: "List the ticket field definitions configured on this Zendesk (both system fields and custom fields), returning each field's id, type, whether it is active/required, and — for dropdown and multiselect fields — the valid option values. Use this to discover the numeric field ids and accepted option tags that create_ticket and update_ticket expect in their custom_fields argument, so a natural-language intent (\"set severity to High\") maps to the right id and a value Zendesk will accept instead of a blind guess. Read-only reference lookup; cursor-paginated in Zendesk's default field order.",
2592
+ inputSchema: z.object({
2593
+ page_size: z.number().int().min(1).max(100).default(100).describe("Ticket field definitions per page (1-100, default 100)."),
2594
+ cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page.")
2595
+ }),
2596
+ annotations: {
2597
+ readOnlyHint: true,
2598
+ destructiveHint: false,
2599
+ idempotentHint: true,
2600
+ openWorldHint: true
2601
+ },
2602
+ handler: async (params) => {
2603
+ const { page_size, cursor } = params;
2604
+ const token = await getToken();
2605
+ const response = await zendeskGet(subdomain, token, "/ticket_fields", buildCursorParams(page_size, cursor));
2606
+ const fields = response.ticket_fields ?? [];
2607
+ return { content: [{
2608
+ type: "text",
2609
+ text: formatList(fields, formatTicketField, extractPaginationMeta(response, fields.length))
2610
+ }] };
2611
+ }
2612
+ },
2613
+ {
2614
+ name: "list_macros",
2615
+ namespace: "tickets",
2616
+ readOnly: true,
2617
+ title: "List Zendesk Macros",
2618
+ description: "List the active macros available to the authenticated user. A macro bundles a canned reply and/or a set of field changes (status, priority, assignee, group, tags, custom fields) an agent applies to a ticket in one gesture; this returns each macro id, title, description, availability scope, and its ordered list of actions, offset-paginated. Results are scoped by per-user OAuth to what the current user can see, so no shared admin key is needed. Pass a macro id from here to preview_macro_diff to preview its effect on a specific ticket.",
2619
+ inputSchema: z.object({
2620
+ per_page: z.number().int().min(1).max(100).default(100).describe(PER_PAGE_DESC),
2621
+ page: z.number().int().min(1).default(1).describe(PAGE_DESC)
2622
+ }),
2623
+ annotations: {
2624
+ readOnlyHint: true,
2625
+ destructiveHint: false,
2626
+ idempotentHint: true,
2627
+ openWorldHint: true
2628
+ },
2629
+ handler: async (params) => {
2630
+ const { per_page, page } = params;
2631
+ const token = await getToken();
2632
+ const response = await zendeskGet(subdomain, token, "/macros/active", buildOffsetParams(per_page, page));
2633
+ const macros = response.macros ?? [];
2634
+ return { content: [{
2635
+ type: "text",
2636
+ text: formatList(macros, formatMacro, extractOffsetPaginationMeta(response, macros.length, per_page, page))
2637
+ }] };
2638
+ }
2639
+ },
2640
+ {
2641
+ name: "preview_macro_diff",
2642
+ namespace: "tickets",
2643
+ readOnly: false,
2644
+ title: "Preview a Macro Diff on a Ticket",
2645
+ description: "Preview the exact changes a macro would make to a specific ticket, as a before → after diff, WITHOUT saving anything. Orchestrates two reads — the ticket's current state and Zendesk's macro-apply preview (which returns the whole resulting ticket) — and returns only the fields the macro actually changes (status, priority, assignee, group, tags, custom fields) plus the canned reply with its public/internal flag; unchanged and identity fields are omitted. Nothing is committed: to apply it, follow up with update_ticket for the field changes and add_public_comment or add_private_note for the reply. This deliberate two-step keeps the mutation explicit and reviewable rather than hidden. Find macro ids via list_macros and the ticket id via search_tickets or list_tickets.",
2646
+ inputSchema: z.object({
2647
+ ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket to preview the macro against. Obtain it from search_tickets or list_tickets."),
2648
+ macro_id: z.number().int().describe("Macro ID — the numeric id of the macro to preview. Obtain it from list_macros.")
2649
+ }),
2650
+ annotations: {
2651
+ readOnlyHint: false,
2652
+ destructiveHint: false,
2653
+ idempotentHint: true,
2654
+ openWorldHint: true
2655
+ },
2656
+ handler: async (params) => {
2657
+ const { ticket_id, macro_id } = params;
2658
+ const token = await getToken();
2659
+ const [{ ticket: before }, { result }] = await Promise.all([zendeskGet(subdomain, token, `/tickets/${ticket_id}`), zendeskGet(subdomain, token, `/tickets/${ticket_id}/macros/${macro_id}/apply`)]);
2660
+ return { content: [{
2661
+ type: "text",
2662
+ text: truncateIfNeeded(formatMacroPreviewDiff(ticket_id, macro_id, before, result))
2452
2663
  }] };
2453
2664
  }
2454
2665
  }
@@ -2473,7 +2684,8 @@ const createUserTools = (ctx) => {
2473
2684
  openWorldHint: true
2474
2685
  },
2475
2686
  handler: async () => {
2476
- const { user } = await zendeskGet(subdomain, await getToken(), "/users/me");
2687
+ const token = await getToken();
2688
+ const { user } = await zendeskGet(subdomain, token, "/users/me");
2477
2689
  return { content: [{
2478
2690
  type: "text",
2479
2691
  text: formatUser(user)
@@ -2499,7 +2711,8 @@ const createUserTools = (ctx) => {
2499
2711
  },
2500
2712
  handler: async (params) => {
2501
2713
  const { query, per_page, page } = params;
2502
- const response = await zendeskGet(subdomain, await getToken(), "/search", {
2714
+ const token = await getToken();
2715
+ const response = await zendeskGet(subdomain, token, "/search", {
2503
2716
  query: `type:user ${query}`,
2504
2717
  ...buildOffsetParams(per_page, page)
2505
2718
  });
@@ -2524,7 +2737,8 @@ const createUserTools = (ctx) => {
2524
2737
  },
2525
2738
  handler: async (params) => {
2526
2739
  const { user_id } = params;
2527
- const { user } = await zendeskGet(subdomain, await getToken(), `/users/${user_id}`);
2740
+ const token = await getToken();
2741
+ const { user } = await zendeskGet(subdomain, token, `/users/${user_id}`);
2528
2742
  return { content: [{
2529
2743
  type: "text",
2530
2744
  text: formatUser(user)
@@ -2546,7 +2760,8 @@ const createUserTools = (ctx) => {
2546
2760
  },
2547
2761
  handler: async (params) => {
2548
2762
  const { organization_id } = params;
2549
- const { organization } = await zendeskGet(subdomain, await getToken(), `/organizations/${organization_id}`);
2763
+ const token = await getToken();
2764
+ const { organization } = await zendeskGet(subdomain, token, `/organizations/${organization_id}`);
2550
2765
  return { content: [{
2551
2766
  type: "text",
2552
2767
  text: formatOrganization(organization)
@@ -2571,7 +2786,8 @@ const createUserTools = (ctx) => {
2571
2786
  },
2572
2787
  handler: async (params) => {
2573
2788
  const { page_size, cursor } = params;
2574
- const response = await zendeskGet(subdomain, await getToken(), "/organizations", buildCursorParams(page_size, cursor));
2789
+ const token = await getToken();
2790
+ const response = await zendeskGet(subdomain, token, "/organizations", buildCursorParams(page_size, cursor));
2575
2791
  const organizations = response.organizations ?? [];
2576
2792
  return { content: [{
2577
2793
  type: "text",
@@ -2899,7 +3115,8 @@ const sendJsonRpcError = (res, status, code, message, headers = {}) => {
2899
3115
  }));
2900
3116
  };
2901
3117
  const sendUnauthorized = (res, resource) => {
2902
- sendJsonRpcError(res, 401, -32e3, MISSING_BEARER_MESSAGE, { "WWW-Authenticate": `Bearer resource_metadata="${resource}/.well-known/oauth-protected-resource", error="invalid_token", error_description="${MISSING_BEARER_MESSAGE}"` });
3118
+ const wwwAuthenticate = `Bearer resource_metadata="${resource}/.well-known/oauth-protected-resource", error="invalid_token", error_description="${MISSING_BEARER_MESSAGE}"`;
3119
+ sendJsonRpcError(res, 401, -32e3, MISSING_BEARER_MESSAGE, { "WWW-Authenticate": wwwAuthenticate });
2903
3120
  };
2904
3121
  const readJsonBody = (req, maxBodyBytes) => new Promise((resolve) => {
2905
3122
  const chunks = [];