@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 +6 -3
- package/dist/index.js +70 -19
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
`
|
|
872
|
-
"
|
|
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
|
|
875
|
-
"
|
|
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,
|
|
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:
|
|
1171
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
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(),
|