@fruggr/zendesk-mcp-server 2.12.0 → 2.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -165,9 +165,12 @@ disabled together with `--no-topology`):
165
165
  read on demand, it returns Markdown describing the active locales (and the
166
166
  default), the category → section tree with IDs, the visibility user segments,
167
167
  the permission groups, and the calling user's role. It is fetched **with the
168
- caller's own token**, so it respects that user's read permissions. On a very
169
- large Help Center the section tree is summarized (per-category, with a pointer
170
- to `list_sections`) to stay concise.
168
+ caller's own token**, so it respects that user's read permissions. Listing the
169
+ permission groups and user segments needs Guide-admin / Help Center manager
170
+ rights; with a content-editor token those two sections are marked *unavailable*
171
+ (not empty) and the rest still renders — reuse those IDs from an existing
172
+ article (`get_article`) instead. On a very large Help Center the section tree is
173
+ summarized (per-category, with a pointer to `list_sections`) to stay concise.
171
174
 
172
175
  Clients that don't consume `instructions` or `resources` simply ignore them —
173
176
  the feature degrades silently. Use `--no-topology` to turn both off server-wide.
package/dist/index.js CHANGED
@@ -868,11 +868,14 @@ const buildInstructions = (config) => {
868
868
  return [
869
869
  `This MCP server is connected to the Zendesk Help Center of "${config.subdomain}".`,
870
870
  "",
871
- `Before creating or editing Help Center content, read the resource ${TOPOLOGY_RESOURCE_URI}.`,
872
- "It describes the active locales (and the default one), the category → section tree with IDs,",
871
+ `When creating or editing Help Center content, the resource ${TOPOLOGY_RESOURCE_URI} is useful context:`,
872
+ "it lists the active locales (and the default one), the category → section tree with IDs,",
873
873
  "the visibility user segments, the permission groups, and your current role.",
874
- "Prefer the IDs from that resource (section_id, permission_group_id, user_segment_id, locale)",
875
- "over guessing from names."
874
+ "Prefer its IDs (section_id, permission_group_id, user_segment_id, locale) over guessing from names.",
875
+ "",
876
+ "It degrades gracefully: without Guide-admin / Help Center manager rights the permission-groups and",
877
+ "user-segments sections are marked unavailable (not empty). In that case reuse a permission_group_id",
878
+ "or user_segment_id from an existing article (get_article) instead."
876
879
  ].join("\n");
877
880
  };
878
881
  //#endregion
@@ -1071,6 +1074,7 @@ const formatArticleSummary = (article) => [
1071
1074
  `## ${article.title} (${article.id})`,
1072
1075
  `- **Locale**: ${article.locale} | **Source locale**: ${article.source_locale}`,
1073
1076
  `- **Section**: ${article.section_id} | **Draft**: ${article.draft}`,
1077
+ `- **Permission group**: ${article.permission_group_id} | **User segment**: ${article.user_segment_id ?? "everyone (no segment)"}`,
1074
1078
  typeof article.position === "number" ? `- **Position**: ${article.position}` : "",
1075
1079
  article.label_names.length > 0 ? `- **Labels**: ${article.label_names.join(", ")}` : "",
1076
1080
  `- **Created**: ${article.created_at} | **Updated**: ${article.updated_at}`
@@ -1143,6 +1147,28 @@ const extractSearchPaginationMeta = (response, perPage, page) => {
1143
1147
  //#endregion
1144
1148
  //#region src/guidance/topology.ts
1145
1149
  /**
1150
+ * Resolve an admin-gated fetch to a sentinel on HTTP 403 instead of rejecting.
1151
+ * Enumerating permission groups and user segments requires Guide-admin / Help
1152
+ * Center manager rights — a tier above per-article editing — so a content-editor
1153
+ * token gets 403 there while the rest of the topology is readable (#161). Any
1154
+ * other failure rethrows; crucially a 401 still propagates so the stale token
1155
+ * gets invalidated (see `onUnauthorized` in `createTopologyProvider`).
1156
+ */
1157
+ const tolerate403 = async (promise, fallback) => {
1158
+ try {
1159
+ return {
1160
+ value: await promise,
1161
+ denied: false
1162
+ };
1163
+ } catch (error) {
1164
+ if (error instanceof ZendeskApiError && error.status === 403) return {
1165
+ value: fallback,
1166
+ denied: true
1167
+ };
1168
+ throw error;
1169
+ }
1170
+ };
1171
+ /**
1146
1172
  * Fetch the structural topology with the CALLER'S token, so the result respects
1147
1173
  * that user's read permissions (no privileged shared credential). Categories
1148
1174
  * and sections are each capped at one max-size page; `sectionsHasMore` /
@@ -1150,12 +1176,12 @@ const extractSearchPaginationMeta = (response, perPage, page) => {
1150
1176
  */
1151
1177
  const fetchTopology = async (subdomain, token) => {
1152
1178
  const pageParams = { "page[size]": String(100) };
1153
- const [locales, categoriesRes, sectionsRes, segmentsRes, permsRes, meRes] = await Promise.all([
1179
+ const [locales, categoriesRes, sectionsRes, segments, perms, meRes] = await Promise.all([
1154
1180
  helpCenterGet(subdomain, token, "/locales"),
1155
1181
  helpCenterGet(subdomain, token, "/categories", pageParams),
1156
1182
  helpCenterGet(subdomain, token, "/sections", pageParams),
1157
- helpCenterGet(subdomain, token, "/user_segments"),
1158
- zendeskGet(subdomain, token, "/guide/permission_groups"),
1183
+ tolerate403(helpCenterGet(subdomain, token, "/user_segments"), { user_segments: [] }),
1184
+ tolerate403(zendeskGet(subdomain, token, "/guide/permission_groups"), { permission_groups: [] }),
1159
1185
  zendeskGet(subdomain, token, "/users/me")
1160
1186
  ]);
1161
1187
  const categories = categoriesRes.categories ?? [];
@@ -1167,8 +1193,10 @@ const fetchTopology = async (subdomain, token) => {
1167
1193
  sections,
1168
1194
  sectionsHasMore: extractPaginationMeta(sectionsRes, sections.length).has_more,
1169
1195
  categoriesHasMore: extractPaginationMeta(categoriesRes, categories.length).has_more,
1170
- userSegments: segmentsRes.user_segments ?? [],
1171
- permissionGroups: permsRes.permission_groups ?? [],
1196
+ userSegments: segments.value.user_segments ?? [],
1197
+ userSegmentsDenied: segments.denied,
1198
+ permissionGroups: perms.value.permission_groups ?? [],
1199
+ permissionGroupsDenied: perms.denied,
1172
1200
  currentUser: meRes.user
1173
1201
  };
1174
1202
  };
@@ -1199,6 +1227,15 @@ const renderTree = (data) => {
1199
1227
  }
1200
1228
  return lines.length ? lines : ["_(no categories)_"];
1201
1229
  };
1230
+ /**
1231
+ * Render an admin-gated section as one of three states so the LLM never mistakes
1232
+ * "you can't see this" for "there are none": the formatted list, `_(none)_` when
1233
+ * genuinely empty, or `deniedNote` when the token was forbidden (403).
1234
+ */
1235
+ const renderAdminSection = (items, denied, deniedNote) => {
1236
+ if (denied) return [deniedNote];
1237
+ return items.length ? items : ["_(none)_"];
1238
+ };
1202
1239
  /** Render the topology as a compact Markdown document for the LLM context. */
1203
1240
  const formatTopology = (data) => {
1204
1241
  return truncateIfNeeded([
@@ -1214,10 +1251,10 @@ const formatTopology = (data) => {
1214
1251
  ...renderTree(data),
1215
1252
  "",
1216
1253
  "## Visibility (user segments)",
1217
- ...data.userSegments.length ? data.userSegments.map(formatUserSegment) : ["_(none)_"],
1254
+ ...renderAdminSection(data.userSegments.map(formatUserSegment), data.userSegmentsDenied, "_Unavailable: listing user segments requires Guide-admin / Help Center manager rights, which this token lacks (HTTP 403). To set visibility, reuse the user_segment_id of an existing article (get_article), or omit it to default to everyone._"),
1218
1255
  "",
1219
1256
  "## Permission groups",
1220
- ...data.permissionGroups.length ? data.permissionGroups.map(formatPermissionGroup) : ["_(none)_"]
1257
+ ...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)._")
1221
1258
  ].join("\n"));
1222
1259
  };
1223
1260
  /**
@@ -1650,9 +1687,16 @@ const createHelpCenterTools = (ctx) => {
1650
1687
  },
1651
1688
  handler: async () => {
1652
1689
  const token = await getToken();
1690
+ let response;
1691
+ try {
1692
+ response = await zendeskGet(subdomain, token, "/guide/permission_groups");
1693
+ } catch (error) {
1694
+ if (error instanceof ZendeskApiError && error.status === 403) throw new Error("list_permission_groups reads Guide permission groups (GET /guide/permission_groups), which Zendesk restricts to Guide admins / Help Center managers. The current token lacks that role (HTTP 403). To obtain a permission_group_id without it, read an existing article with get_article and reuse its permission_group_id.", { cause: error });
1695
+ throw error;
1696
+ }
1653
1697
  return { content: [{
1654
1698
  type: "text",
1655
- text: formatList((await zendeskGet(subdomain, token, "/guide/permission_groups")).permission_groups ?? [], formatPermissionGroup)
1699
+ text: formatList(response.permission_groups ?? [], formatPermissionGroup)
1656
1700
  }] };
1657
1701
  }
1658
1702
  },
@@ -1666,8 +1710,8 @@ const createHelpCenterTools = (ctx) => {
1666
1710
  section_id: z.number().int().describe("Section that will contain the article (numeric id from list_sections)."),
1667
1711
  title: z.string().min(1).describe("Title of the new article, in its source locale."),
1668
1712
  body: z.string().min(1).describe("Article body as HTML (this becomes the source-locale content)."),
1669
- permission_group_id: z.number().int().describe("Permission group ID (use list_permission_groups to find it)"),
1670
- user_segment_id: z.number().int().optional().describe("User segment ID for visibility (use list_user_segments to find it). Defaults to everyone."),
1713
+ permission_group_id: z.number().int().describe("Permission group ID (use list_permission_groups to find it; if that is forbidden because the token is not a Guide admin, reuse the permission_group_id of an existing article from get_article)."),
1714
+ user_segment_id: z.number().int().optional().describe("User segment ID for visibility (use list_user_segments to find it; if that is forbidden because the token is not a Guide admin, reuse the user_segment_id of an existing article from get_article). Defaults to everyone."),
1671
1715
  author_id: z.number().int().optional().describe("Author user ID. Defaults to the authenticated user."),
1672
1716
  content_tag_ids: z.array(z.string()).optional().describe("Content tag IDs (use list_content_tags to find them)"),
1673
1717
  locale: z.string().optional().describe("Source locale for the article, e.g. \"en-us\" or \"fr\". Defaults to the Help Center's default locale; becomes the article's source_locale."),
@@ -1703,9 +1747,9 @@ const createHelpCenterTools = (ctx) => {
1703
1747
  promoted: z.boolean().optional().describe("Set true to promote (feature) the article in its section, or false to unpromote it."),
1704
1748
  label_names: z.array(z.string()).optional().describe("Label names for search ranking (use list_labels to see existing labels)."),
1705
1749
  content_tag_ids: z.array(z.string()).optional().describe("Content tag ids to attach (use list_content_tags to find them)."),
1706
- user_segment_id: z.number().int().optional().describe("User segment that controls who can see the article (id from list_user_segments)."),
1750
+ user_segment_id: z.number().int().optional().describe("User segment that controls who can see the article (id from list_user_segments; if that is forbidden because the token is not a Guide admin, reuse the user_segment_id of an existing article from get_article)."),
1707
1751
  author_id: z.number().int().optional().describe("User id of the article author (from search_users)."),
1708
- permission_group_id: z.number().int().optional().describe("Guide permission group controlling who can edit (id from list_permission_groups)."),
1752
+ permission_group_id: z.number().int().optional().describe("Guide permission group controlling who can edit (id from list_permission_groups; if that is forbidden because the token is not a Guide admin, reuse the permission_group_id of an existing article from get_article)."),
1709
1753
  section_id: z.number().int().optional().describe("Move the article to this section (numeric id from list_sections)."),
1710
1754
  position: z.number().int().min(0).optional().describe("Sort position within the section (manual ordering only; 0 = first/top). New articles default to position 0. To move an article to the END of its section, set this to one more than the highest current position: read the highest position P from list_articles with sort_by=\"position\", sort_order=\"desc\", then set position = P + 1.")
1711
1755
  }),
@@ -1762,7 +1806,7 @@ const createHelpCenterTools = (ctx) => {
1762
1806
  name_prefix: z.string().min(1).optional().describe("Return only content tags whose name starts with this prefix (prefix match — not a substring or fuzzy search). Use the full name to check whether a specific tag already exists before creating it."),
1763
1807
  sort_by: z.enum(["name", "id"]).default("name").describe("Field to sort by; \"name\" (the default) lists tags alphabetically."),
1764
1808
  sort_order: z.enum(["asc", "desc"]).default("asc").describe("Sort direction: ascending or descending."),
1765
- page_size: z.number().int().min(1).max(100).default(100).describe("Content tags per page (1-100, default 100)."),
1809
+ page_size: z.number().int().min(1).max(30).default(30).describe("Content tags per page (1-30, default 30). The Guide content-tags endpoint caps each page at 30; follow the returned cursor to enumerate the full list."),
1766
1810
  cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page.")
1767
1811
  }),
1768
1812
  annotations: {
@@ -1847,9 +1891,16 @@ const createHelpCenterTools = (ctx) => {
1847
1891
  },
1848
1892
  handler: async () => {
1849
1893
  const token = await getToken();
1894
+ let response;
1895
+ try {
1896
+ response = await helpCenterGet(subdomain, token, "/user_segments");
1897
+ } catch (error) {
1898
+ if (error instanceof ZendeskApiError && error.status === 403) throw new Error("list_user_segments reads Help Center user segments (GET /help_center/user_segments), which Zendesk restricts to Guide admins / Help Center managers. The current token lacks that role (HTTP 403). To set an article's visibility without it, reuse the user_segment_id of an existing article (get_article), or omit user_segment_id when creating/updating to default to everyone.", { cause: error });
1899
+ throw error;
1900
+ }
1850
1901
  return { content: [{
1851
1902
  type: "text",
1852
- text: formatList((await helpCenterGet(subdomain, token, "/user_segments")).user_segments ?? [], formatUserSegment)
1903
+ text: formatList(response.user_segments ?? [], formatUserSegment)
1853
1904
  }] };
1854
1905
  }
1855
1906
  },
@@ -3292,7 +3343,7 @@ const registerToolset = (server, { config, getToken, onUnauthorized, logger = si
3292
3343
  const topology = createTopologyProvider(getToken, config.subdomain, onUnauthorized);
3293
3344
  registered.push(server.registerResource("help-center-topology", TOPOLOGY_RESOURCE_URI, {
3294
3345
  title: "Zendesk Help Center topology",
3295
- description: "Active locales, category → section tree, visibility segments, permission groups, and your role. Read before creating or editing content.",
3346
+ description: "Active locales, category → section tree, visibility segments, permission groups, and your role. Useful context when creating or editing content; admin-only sections (permission groups, user segments) are marked unavailable rather than empty when your role lacks Guide-admin rights.",
3296
3347
  mimeType: "text/markdown"
3297
3348
  }, async (uri) => ({ contents: [{
3298
3349
  uri: uri.toString(),