@fruggr/zendesk-mcp-server 2.0.0 → 2.1.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
@@ -151,6 +151,27 @@ zendesk-mcp-server acme --namespace tickets
151
151
 
152
152
  </details>
153
153
 
154
+ ## Help Center context (instructions + resources)
155
+
156
+ Beyond tools, the server hands an LLM the structural context it needs to work
157
+ against *your* Help Center — so it stops guessing locales or fuzzy-matching
158
+ section names and uses real IDs instead. This is delivered through two
159
+ MCP-native channels (both active only when the `help_center` namespace is, and
160
+ disabled together with `--no-topology`):
161
+
162
+ - **`instructions`** (sent on `initialize`): a short, static blob auto-loaded by
163
+ compliant clients. It names the subdomain and points at the topology resource.
164
+ - **`zendesk-hc://topology`** (a pull-only [MCP resource](https://modelcontextprotocol.io/docs/concepts/resources)):
165
+ read on demand, it returns Markdown describing the active locales (and the
166
+ default), the category → section tree with IDs, the visibility user segments,
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.
171
+
172
+ Clients that don't consume `instructions` or `resources` simply ignore them —
173
+ the feature degrades silently. Use `--no-topology` to turn both off server-wide.
174
+
154
175
  ## Prerequisites
155
176
 
156
177
  - **Node.js** >= 20 (runtime — declared in `package.json#engines.node`)
@@ -418,6 +439,8 @@ Options:
418
439
  --namespace <ns> Filter by namespace (repeatable): tickets, help_center, users
419
440
  --tool <name> Filter by tool name (repeatable, forces --mode all)
420
441
  --read-only Only expose read operations
442
+ --no-topology Disable the Help Center structural context
443
+ (instructions + zendesk-hc://topology resource)
421
444
  --log-level <level> debug | info (default) | warn | error
422
445
  --transport <t> stdio (default) | http
423
446
  --host <host> HTTP bind host (default: 0.0.0.0)
package/dist/index.js CHANGED
@@ -566,6 +566,14 @@ const ConfigSchema = z.object({
566
566
  readOnly: z.boolean(),
567
567
  namespaces: z.array(Namespace).optional(),
568
568
  tools: z.array(z.string()).optional(),
569
+ /**
570
+ * Whether to expose the Help Center structural context (the `instructions`
571
+ * blob + the `zendesk-hc://topology` resource). On by default; an operator
572
+ * disables it server-wide with `--no-topology` (e.g. on a very large Help
573
+ * Center, or when the context is unwanted). Only ever active when the
574
+ * `help_center` namespace itself is active.
575
+ */
576
+ topology: z.boolean().default(true),
569
577
  transport: Transport,
570
578
  host: z.string().min(1),
571
579
  port: z.number().int().min(0).max(65535),
@@ -596,6 +604,7 @@ const parseCliArgs = (args) => {
596
604
  result.mode = next;
597
605
  i++;
598
606
  } else if (arg === "--read-only") result.readOnly = true;
607
+ else if (arg === "--no-topology") result.topology = false;
599
608
  else if (arg === "--namespace" && next) {
600
609
  result.namespaces = result.namespaces ?? [];
601
610
  result.namespaces.push(next);
@@ -653,6 +662,7 @@ const loadConfig = (argv = process.argv.slice(2)) => {
653
662
  readOnly: cli.readOnly ?? false,
654
663
  namespaces: cli.namespaces,
655
664
  tools: cli.tools,
665
+ topology: cli.topology ?? true,
656
666
  transport,
657
667
  host,
658
668
  port,
@@ -771,6 +781,246 @@ const helpCenterUpload = async (subdomain, token, path, formData) => {
771
781
  return response.json();
772
782
  };
773
783
  //#endregion
784
+ //#region src/guidance/instructions.ts
785
+ /** Stable URI of the dynamic Help Center topology resource. */
786
+ const TOPOLOGY_RESOURCE_URI = "zendesk-hc://topology";
787
+ /**
788
+ * Whether the Help Center structural context (init instructions + the
789
+ * `zendesk-hc://topology` resource) should be exposed. True only when the
790
+ * feature is enabled (`--no-topology` not set) AND the `help_center` namespace
791
+ * is active (no `--namespace` filter, or one that includes it). Shared by the
792
+ * instructions builder and the resource registration in `server.ts` so both
793
+ * gates stay in sync.
794
+ */
795
+ const helpCenterContextEnabled = (config) => config.topology && (!config.namespaces?.length || config.namespaces.includes("help_center"));
796
+ /**
797
+ * The static `instructions` blob sent on `initialize`. Deliberately short and
798
+ * I/O-free: it must not trigger the lazy OAuth/PKCE flow just to connect, and
799
+ * it stays within a tight token budget. The rich, dynamic topology lives in the
800
+ * pull-only `zendesk-hc://topology` resource referenced here.
801
+ */
802
+ const buildInstructions = (config) => {
803
+ if (!helpCenterContextEnabled(config)) return void 0;
804
+ return [
805
+ `This MCP server is connected to the Zendesk Help Center of "${config.subdomain}".`,
806
+ "",
807
+ `Before creating or editing Help Center content, read the resource ${TOPOLOGY_RESOURCE_URI}.`,
808
+ "It describes the active locales (and the default one), the category → section tree with IDs,",
809
+ "the visibility user segments, the permission groups, and your current role.",
810
+ "Prefer the IDs from that resource (section_id, permission_group_id, user_segment_id, locale)",
811
+ "over guessing from names."
812
+ ].join("\n");
813
+ };
814
+ //#endregion
815
+ //#region src/utils/formatting.ts
816
+ const truncateIfNeeded = (text) => {
817
+ if (text.length <= 25e3) return text;
818
+ return `${text.slice(0, CHARACTER_LIMIT)}\n\n--- Response truncated (${text.length} chars, limit ${CHARACTER_LIMIT}). Use pagination or filters to reduce results. ---`;
819
+ };
820
+ const formatPagination = (meta) => {
821
+ const parts = [`Results: ${meta.count}`];
822
+ if (meta.has_more) parts.push(`More available (cursor: ${meta.after_cursor})`);
823
+ return parts.join(" | ");
824
+ };
825
+ const formatTicket = (ticket) => [
826
+ `## Ticket #${ticket.id}: ${ticket.subject}`,
827
+ `- **Status**: ${ticket.status} | **Priority**: ${ticket.priority ?? "none"} | **Type**: ${ticket.type ?? "none"}`,
828
+ `- **Requester**: ${ticket.requester_id} | **Assignee**: ${ticket.assignee_id ?? "unassigned"}`,
829
+ `- **Tags**: ${ticket.tags.length > 0 ? ticket.tags.join(", ") : "none"}`,
830
+ `- **Created**: ${ticket.created_at} | **Updated**: ${ticket.updated_at}`,
831
+ ticket.description ? `\n${ticket.description}` : ""
832
+ ].filter(Boolean).join("\n");
833
+ const formatComment = (comment) => {
834
+ const lines = [`### ${comment.public ? "Public comment" : "Internal note"} by ${comment.author_id}`, `*${comment.created_at}*`];
835
+ if (comment.attachments?.length) {
836
+ const summary = comment.attachments.map((a) => `#${a.id} (${a.content_type})`).join(", ");
837
+ lines.push(`Attachments: ${summary}`);
838
+ }
839
+ lines.push("", comment.body);
840
+ return lines.join("\n");
841
+ };
842
+ const formatUser = (user) => [
843
+ `## ${user.name} (${user.id})`,
844
+ `- **Email**: ${user.email}`,
845
+ `- **Role**: ${user.role}`,
846
+ user.role_type != null ? `- **Role type**: ${user.role_type}` : "",
847
+ `- **Active**: ${user.active}`,
848
+ user.organization_id ? `- **Organization**: ${user.organization_id}` : ""
849
+ ].filter(Boolean).join("\n");
850
+ const formatOrganization = (org) => [
851
+ `## ${org.name} (${org.id})`,
852
+ org.details ? `- **Details**: ${org.details}` : "",
853
+ org.domain_names.length > 0 ? `- **Domains**: ${org.domain_names.join(", ")}` : "",
854
+ org.tags.length > 0 ? `- **Tags**: ${org.tags.join(", ")}` : ""
855
+ ].filter(Boolean).join("\n");
856
+ const formatArticleSummary = (article) => [
857
+ `## ${article.title} (${article.id})`,
858
+ `- **Locale**: ${article.locale} | **Source locale**: ${article.source_locale}`,
859
+ `- **Section**: ${article.section_id} | **Draft**: ${article.draft}`,
860
+ typeof article.position === "number" ? `- **Position**: ${article.position}` : "",
861
+ article.label_names.length > 0 ? `- **Labels**: ${article.label_names.join(", ")}` : "",
862
+ `- **Created**: ${article.created_at} | **Updated**: ${article.updated_at}`
863
+ ].filter(Boolean).join("\n");
864
+ const formatArticle = (article) => [
865
+ formatArticleSummary(article),
866
+ "",
867
+ article.body
868
+ ].join("\n");
869
+ const formatTranslationSummary = (translation) => [
870
+ `## Translation: ${translation.locale} (${translation.id})`,
871
+ `- **Title**: ${translation.title}`,
872
+ `- **Draft**: ${translation.draft}`,
873
+ `- **Updated**: ${translation.updated_at}`
874
+ ].join("\n");
875
+ const formatTranslation = (translation) => [
876
+ formatTranslationSummary(translation),
877
+ "",
878
+ translation.body
879
+ ].join("\n");
880
+ const formatCategory = (category) => `- **${category.name}** (${category.id}) — ${category.description || "No description"}`;
881
+ const formatSection = (section) => `- **${section.name}** (${section.id}) — Category: ${section.category_id} — ${section.description || "No description"}`;
882
+ const formatPermissionGroup = (group) => `- **${group.name}** (${group.id})${group.built_in ? " — Built-in" : ""}`;
883
+ const formatContentTag = (tag) => `- **${tag.name}** (${tag.id})`;
884
+ const formatLabel = (label) => `- **${label.name}** (${label.id})`;
885
+ const formatUserSegment = (segment) => `- **${segment.name}** (${segment.id}) — ${segment.user_type}${segment.built_in ? " — Built-in" : ""}`;
886
+ const formatAttachment = (attachment) => `- **${attachment.file_name}** (${attachment.id}) — ${attachment.content_type} — ${attachment.size} bytes`;
887
+ const formatList = (items, formatter, meta) => {
888
+ return truncateIfNeeded([meta ? formatPagination(meta) : "", items.map(formatter).join("\n\n")].filter(Boolean).join("\n\n"));
889
+ };
890
+ //#endregion
891
+ //#region src/utils/pagination.ts
892
+ const buildCursorParams = (pageSize, cursor) => {
893
+ const params = { "page[size]": String(pageSize) };
894
+ if (cursor) params["page[after]"] = cursor;
895
+ return params;
896
+ };
897
+ const buildOffsetParams = (perPage, page) => {
898
+ const params = { per_page: String(perPage) };
899
+ if (page && page > 1) params["page"] = String(page);
900
+ return params;
901
+ };
902
+ const extractPaginationMeta = (response) => ({
903
+ has_more: response.meta?.has_more ?? response.next_page != null,
904
+ after_cursor: response.meta?.after_cursor ?? null,
905
+ count: response.count ?? 0
906
+ });
907
+ const extractSearchPaginationMeta = (response, perPage, page) => {
908
+ const count = response.count ?? 0;
909
+ const has_more = count > page * perPage;
910
+ return {
911
+ has_more,
912
+ after_cursor: has_more ? String(page + 1) : null,
913
+ count
914
+ };
915
+ };
916
+ //#endregion
917
+ //#region src/guidance/topology.ts
918
+ /**
919
+ * Fetch the structural topology with the CALLER'S token, so the result respects
920
+ * that user's read permissions (no privileged shared credential). Categories
921
+ * and sections are each capped at one max-size page; `sectionsHasMore` /
922
+ * `categoriesHasMore` signal a Help Center too large to enumerate inline.
923
+ */
924
+ const fetchTopology = async (subdomain, token) => {
925
+ const pageParams = { "page[size]": String(100) };
926
+ const [locales, categoriesRes, sectionsRes, segmentsRes, permsRes, meRes] = await Promise.all([
927
+ helpCenterGet(subdomain, token, "/locales"),
928
+ helpCenterGet(subdomain, token, "/categories", pageParams),
929
+ helpCenterGet(subdomain, token, "/sections", pageParams),
930
+ helpCenterGet(subdomain, token, "/user_segments"),
931
+ zendeskGet(subdomain, token, "/guide/permission_groups"),
932
+ zendeskGet(subdomain, token, "/users/me")
933
+ ]);
934
+ return {
935
+ subdomain,
936
+ locales,
937
+ categories: categoriesRes.categories ?? [],
938
+ sections: sectionsRes.sections ?? [],
939
+ sectionsHasMore: extractPaginationMeta(sectionsRes).has_more,
940
+ categoriesHasMore: extractPaginationMeta(categoriesRes).has_more,
941
+ userSegments: segmentsRes.user_segments ?? [],
942
+ permissionGroups: permsRes.permission_groups ?? [],
943
+ currentUser: meRes.user
944
+ };
945
+ };
946
+ const renderTree = (data) => {
947
+ if (data.categoriesHasMore || data.sectionsHasMore) {
948
+ const reasons = [];
949
+ if (data.categoriesHasMore) reasons.push(`more than 100 categories`);
950
+ if (data.sectionsHasMore) reasons.push(`more than 100 sections`);
951
+ return [
952
+ `Large Help Center (${reasons.join(" and ")}) — the full tree is omitted to stay concise.`,
953
+ data.categoriesHasMore ? "Categories (partial list):" : "Categories:",
954
+ ...data.categories.map(formatCategory),
955
+ "",
956
+ ...data.categoriesHasMore ? ["Use the `list_categories` tool to enumerate all categories."] : [],
957
+ "Use the `list_sections` tool (filtered by `category_id`) to enumerate sections under a category."
958
+ ];
959
+ }
960
+ const byCategory = /* @__PURE__ */ new Map();
961
+ for (const section of data.sections) {
962
+ const list = byCategory.get(section.category_id) ?? [];
963
+ list.push(section);
964
+ byCategory.set(section.category_id, list);
965
+ }
966
+ const lines = [];
967
+ for (const category of data.categories) {
968
+ lines.push(formatCategory(category));
969
+ for (const section of byCategory.get(category.id) ?? []) lines.push(` ${formatSection(section)}`);
970
+ }
971
+ return lines.length ? lines : ["_(no categories)_"];
972
+ };
973
+ /** Render the topology as a compact Markdown document for the LLM context. */
974
+ const formatTopology = (data) => {
975
+ return truncateIfNeeded([
976
+ `# Zendesk Help Center topology — ${data.subdomain}`,
977
+ "",
978
+ `**Your access**: ${data.currentUser.name} (id ${data.currentUser.id}), role "${data.currentUser.role}".`,
979
+ "",
980
+ "## Locales",
981
+ `- Default: ${data.locales.default_locale}`,
982
+ `- Active: ${data.locales.locales.join(", ")}`,
983
+ "",
984
+ "## Categories → sections",
985
+ ...renderTree(data),
986
+ "",
987
+ "## Visibility (user segments)",
988
+ ...data.userSegments.length ? data.userSegments.map(formatUserSegment) : ["_(none)_"],
989
+ "",
990
+ "## Permission groups",
991
+ ...data.permissionGroups.length ? data.permissionGroups.map(formatPermissionGroup) : ["_(none)_"]
992
+ ].join("\n"));
993
+ };
994
+ /**
995
+ * Build a topology provider holding a memoized-promise cache (TTL
996
+ * `TOPOLOGY_TTL_MS`). In HTTP mode `createMcpServer` — and therefore this
997
+ * provider — is instantiated PER SESSION, so this cache is per-session /
998
+ * per-caller: it must NOT be hoisted to module scope, or one tenant's structure
999
+ * would leak to another. The promise (not the value) is cached to coalesce
1000
+ * concurrent reads; failures are evicted so the next read retries with a fresh
1001
+ * token. A 401 notifies `onUnauthorized` (stdio OAuth only) to invalidate the
1002
+ * stale token, mirroring the tool dispatch path in `server.ts`.
1003
+ */
1004
+ const createTopologyProvider = (getToken, subdomain, onUnauthorized) => {
1005
+ let cached;
1006
+ return { read() {
1007
+ const now = Date.now();
1008
+ if (cached && now - cached.at < 3e5) return cached.promise;
1009
+ const promise = (async () => {
1010
+ return formatTopology(await fetchTopology(subdomain, await getToken()));
1011
+ })().catch((err) => {
1012
+ cached = void 0;
1013
+ if (onUnauthorized && err instanceof ZendeskApiError && err.status === 401) onUnauthorized();
1014
+ throw err;
1015
+ });
1016
+ cached = {
1017
+ at: now,
1018
+ promise
1019
+ };
1020
+ return promise;
1021
+ } };
1022
+ };
1023
+ //#endregion
774
1024
  //#region src/routing/registry.ts
775
1025
  const filterTools = (allTools, options) => allTools.filter((tool) => {
776
1026
  if (options.readOnly && !tool.readOnly) return false;
@@ -883,108 +1133,6 @@ const markdownToHtml = (markdown) => {
883
1133
  return String(mdToHtmlProcessor.processSync(markdown));
884
1134
  };
885
1135
  //#endregion
886
- //#region src/utils/formatting.ts
887
- const truncateIfNeeded = (text) => {
888
- if (text.length <= 25e3) return text;
889
- return `${text.slice(0, CHARACTER_LIMIT)}\n\n--- Response truncated (${text.length} chars, limit ${CHARACTER_LIMIT}). Use pagination or filters to reduce results. ---`;
890
- };
891
- const formatPagination = (meta) => {
892
- const parts = [`Results: ${meta.count}`];
893
- if (meta.has_more) parts.push(`More available (cursor: ${meta.after_cursor})`);
894
- return parts.join(" | ");
895
- };
896
- const formatTicket = (ticket) => [
897
- `## Ticket #${ticket.id}: ${ticket.subject}`,
898
- `- **Status**: ${ticket.status} | **Priority**: ${ticket.priority ?? "none"} | **Type**: ${ticket.type ?? "none"}`,
899
- `- **Requester**: ${ticket.requester_id} | **Assignee**: ${ticket.assignee_id ?? "unassigned"}`,
900
- `- **Tags**: ${ticket.tags.length > 0 ? ticket.tags.join(", ") : "none"}`,
901
- `- **Created**: ${ticket.created_at} | **Updated**: ${ticket.updated_at}`,
902
- ticket.description ? `\n${ticket.description}` : ""
903
- ].filter(Boolean).join("\n");
904
- const formatComment = (comment) => {
905
- const lines = [`### ${comment.public ? "Public comment" : "Internal note"} by ${comment.author_id}`, `*${comment.created_at}*`];
906
- if (comment.attachments?.length) {
907
- const summary = comment.attachments.map((a) => `#${a.id} (${a.content_type})`).join(", ");
908
- lines.push(`Attachments: ${summary}`);
909
- }
910
- lines.push("", comment.body);
911
- return lines.join("\n");
912
- };
913
- const formatUser = (user) => [
914
- `## ${user.name} (${user.id})`,
915
- `- **Email**: ${user.email}`,
916
- `- **Role**: ${user.role}`,
917
- user.role_type != null ? `- **Role type**: ${user.role_type}` : "",
918
- `- **Active**: ${user.active}`,
919
- user.organization_id ? `- **Organization**: ${user.organization_id}` : ""
920
- ].filter(Boolean).join("\n");
921
- const formatOrganization = (org) => [
922
- `## ${org.name} (${org.id})`,
923
- org.details ? `- **Details**: ${org.details}` : "",
924
- org.domain_names.length > 0 ? `- **Domains**: ${org.domain_names.join(", ")}` : "",
925
- org.tags.length > 0 ? `- **Tags**: ${org.tags.join(", ")}` : ""
926
- ].filter(Boolean).join("\n");
927
- const formatArticleSummary = (article) => [
928
- `## ${article.title} (${article.id})`,
929
- `- **Locale**: ${article.locale} | **Source locale**: ${article.source_locale}`,
930
- `- **Section**: ${article.section_id} | **Draft**: ${article.draft}`,
931
- typeof article.position === "number" ? `- **Position**: ${article.position}` : "",
932
- article.label_names.length > 0 ? `- **Labels**: ${article.label_names.join(", ")}` : "",
933
- `- **Created**: ${article.created_at} | **Updated**: ${article.updated_at}`
934
- ].filter(Boolean).join("\n");
935
- const formatArticle = (article) => [
936
- formatArticleSummary(article),
937
- "",
938
- article.body
939
- ].join("\n");
940
- const formatTranslationSummary = (translation) => [
941
- `## Translation: ${translation.locale} (${translation.id})`,
942
- `- **Title**: ${translation.title}`,
943
- `- **Draft**: ${translation.draft}`,
944
- `- **Updated**: ${translation.updated_at}`
945
- ].join("\n");
946
- const formatTranslation = (translation) => [
947
- formatTranslationSummary(translation),
948
- "",
949
- translation.body
950
- ].join("\n");
951
- const formatCategory = (category) => `- **${category.name}** (${category.id}) — ${category.description || "No description"}`;
952
- const formatSection = (section) => `- **${section.name}** (${section.id}) — Category: ${section.category_id} — ${section.description || "No description"}`;
953
- const formatPermissionGroup = (group) => `- **${group.name}** (${group.id})${group.built_in ? " — Built-in" : ""}`;
954
- const formatContentTag = (tag) => `- **${tag.name}** (${tag.id})`;
955
- const formatLabel = (label) => `- **${label.name}** (${label.id})`;
956
- const formatUserSegment = (segment) => `- **${segment.name}** (${segment.id}) — ${segment.user_type}${segment.built_in ? " — Built-in" : ""}`;
957
- const formatAttachment = (attachment) => `- **${attachment.file_name}** (${attachment.id}) — ${attachment.content_type} — ${attachment.size} bytes`;
958
- const formatList = (items, formatter, meta) => {
959
- return truncateIfNeeded([meta ? formatPagination(meta) : "", items.map(formatter).join("\n\n")].filter(Boolean).join("\n\n"));
960
- };
961
- //#endregion
962
- //#region src/utils/pagination.ts
963
- const buildCursorParams = (pageSize, cursor) => {
964
- const params = { "page[size]": String(pageSize) };
965
- if (cursor) params["page[after]"] = cursor;
966
- return params;
967
- };
968
- const buildOffsetParams = (perPage, page) => {
969
- const params = { per_page: String(perPage) };
970
- if (page && page > 1) params["page"] = String(page);
971
- return params;
972
- };
973
- const extractPaginationMeta = (response) => ({
974
- has_more: response.meta?.has_more ?? response.next_page != null,
975
- after_cursor: response.meta?.after_cursor ?? null,
976
- count: response.count ?? 0
977
- });
978
- const extractSearchPaginationMeta = (response, perPage, page) => {
979
- const count = response.count ?? 0;
980
- const has_more = count > page * perPage;
981
- return {
982
- has_more,
983
- after_cursor: has_more ? String(page + 1) : null,
984
- count
985
- };
986
- };
987
- //#endregion
988
1136
  //#region src/tools/help-center.ts
989
1137
  const largeArticleHint = (body, sectionCount) => {
990
1138
  if (body.length < 3e3 && sectionCount < 4) return null;
@@ -1063,11 +1211,11 @@ const createHelpCenterTools = (ctx) => {
1063
1211
  namespace: "help_center",
1064
1212
  readOnly: true,
1065
1213
  title: "List Help Center Categories",
1066
- description: "List all Help Center categories. Optionally filter by locale.",
1214
+ description: "List all Help Center categories. Categories are the top level of the Guide hierarchy (category → section → article); each entry includes its id, name and locale. Results are cursor-paginated. Pair a returned category id with list_sections to drill down, then list_articles to reach articles. Pass a locale to read category names in that translation.",
1067
1215
  inputSchema: z.object({
1068
- locale: z.string().optional(),
1069
- page_size: z.number().int().min(1).max(100).default(100),
1070
- cursor: z.string().optional()
1216
+ locale: z.string().optional().describe("Locale for category names (e.g., \"en-us\", \"fr\"). Defaults to the Help Center default locale."),
1217
+ page_size: z.number().int().min(1).max(100).default(100).describe("Categories per page (1-100, default 100)."),
1218
+ cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page.")
1071
1219
  }),
1072
1220
  annotations: {
1073
1221
  readOnlyHint: true,
@@ -1089,12 +1237,12 @@ const createHelpCenterTools = (ctx) => {
1089
1237
  namespace: "help_center",
1090
1238
  readOnly: true,
1091
1239
  title: "List Help Center Sections",
1092
- description: "List sections, optionally filtered by category ID and locale.",
1240
+ description: "List Help Center sections. Sections are the middle level of the Guide hierarchy (category section → article) and group related articles; each entry includes its id, name, category_id and locale. Results are cursor-paginated. Pass category_id to list only one category's sections (ids come from list_categories), then use a section id with list_articles. Pass a locale to read section names in that translation.",
1093
1241
  inputSchema: z.object({
1094
- category_id: z.number().int().optional(),
1095
- locale: z.string().optional(),
1096
- page_size: z.number().int().min(1).max(100).default(100),
1097
- cursor: z.string().optional()
1242
+ category_id: z.number().int().optional().describe("Restrict to sections of this category (id from list_categories). Omit to list every section."),
1243
+ locale: z.string().optional().describe("Locale for section names (e.g., \"en-us\", \"fr\"). Defaults to the Help Center default locale."),
1244
+ page_size: z.number().int().min(1).max(100).default(100).describe("Sections per page (1-100, default 100)."),
1245
+ cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page.")
1098
1246
  }),
1099
1247
  annotations: {
1100
1248
  readOnlyHint: true,
@@ -1189,13 +1337,13 @@ const createHelpCenterTools = (ctx) => {
1189
1337
  namespace: "help_center",
1190
1338
  readOnly: false,
1191
1339
  title: "Create Article Translation",
1192
- description: "Create a translation for an existing article in a specific locale.",
1340
+ description: "Create a translation for an existing article in a specific locale. The article must already exist (create it with create_article); this adds a new localized version and returns the created translation (locale, title, draft state). The target locale must not already have a translation — use update_article_translation to modify an existing one, and list_article_translations to see which locales exist. Provide the full HTML body.",
1193
1341
  inputSchema: z.object({
1194
- article_id: z.number().int(),
1342
+ article_id: z.number().int().describe("ID of the existing article to translate."),
1195
1343
  locale: z.string().describe("Target locale (e.g., \"fr\", \"de\")"),
1196
- title: z.string().min(1),
1344
+ title: z.string().min(1).describe("Translated article title."),
1197
1345
  body: z.string().min(1).describe("Translated body (HTML)"),
1198
- draft: z.boolean().default(false)
1346
+ draft: z.boolean().default(false).describe("Create the translation as a draft (not visible to end users). Defaults to false (published).")
1199
1347
  }),
1200
1348
  annotations: {
1201
1349
  readOnlyHint: false,
@@ -1357,8 +1505,8 @@ const createHelpCenterTools = (ctx) => {
1357
1505
  namespace: "help_center",
1358
1506
  readOnly: false,
1359
1507
  title: "Create Content Tag",
1360
- description: "Create a new content tag for Guide articles.",
1361
- inputSchema: z.object({ name: z.string().min(1).describe("Content tag name") }),
1508
+ description: "Create a new content tag for Guide articles. Content tags are end-user visible labels that help readers discover related articles; this returns the created tag with its id. Check list_content_tags first to avoid duplicates, then attach the new id via the content_tag_ids parameter of create_article or update_article. For internal search-ranking labels that are not shown to end users, use article labels (list_labels) instead.",
1509
+ inputSchema: z.object({ name: z.string().min(1).describe("Content tag name as shown to end users (e.g., \"billing\", \"getting-started\").") }),
1362
1510
  annotations: {
1363
1511
  readOnlyHint: false,
1364
1512
  destructiveHint: false,
@@ -1419,8 +1567,8 @@ const createHelpCenterTools = (ctx) => {
1419
1567
  namespace: "help_center",
1420
1568
  readOnly: true,
1421
1569
  title: "List Article Attachments",
1422
- description: "List all attachments for an article.",
1423
- inputSchema: z.object({ article_id: z.number().int().describe("Article ID") }),
1570
+ description: "List all attachments for an article. Returns attachment metadata only (id, file name, content type, size, URL), not the file bytes; both inline and block attachments are included. This is for Help Center articles — for attachments on support tickets use get_ticket_attachments instead. Upload new files with create_article_attachment.",
1571
+ inputSchema: z.object({ article_id: z.number().int().describe("ID of the Help Center article whose attachments to list.") }),
1424
1572
  annotations: {
1425
1573
  readOnlyHint: true,
1426
1574
  destructiveHint: false,
@@ -1851,7 +1999,7 @@ const createTicketTools = (ctx) => {
1851
1999
  namespace: "tickets",
1852
2000
  readOnly: false,
1853
2001
  title: "Create Zendesk Ticket",
1854
- description: "Create a new Zendesk support ticket with subject, description, and optional priority/type/assignee/tags.",
2002
+ 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.",
1855
2003
  inputSchema: z.object({
1856
2004
  subject: z.string().min(1).describe("Ticket subject"),
1857
2005
  description: z.string().min(1).describe("Ticket description"),
@@ -1860,20 +2008,20 @@ const createTicketTools = (ctx) => {
1860
2008
  "high",
1861
2009
  "normal",
1862
2010
  "low"
1863
- ]).optional(),
2011
+ ]).optional().describe("Ticket priority. One of urgent, high, normal, low."),
1864
2012
  type: z.enum([
1865
2013
  "problem",
1866
2014
  "incident",
1867
2015
  "question",
1868
2016
  "task"
1869
- ]).optional(),
1870
- assignee_id: z.number().int().optional(),
1871
- group_id: z.number().int().optional(),
1872
- tags: z.array(z.string()).optional(),
2017
+ ]).optional().describe("Ticket type. One of problem, incident, question, task."),
2018
+ assignee_id: z.number().int().optional().describe("User id of the agent to assign the ticket to."),
2019
+ group_id: z.number().int().optional().describe("Id of the group to assign the ticket to."),
2020
+ tags: z.array(z.string()).optional().describe("Tags to set on the ticket."),
1873
2021
  custom_fields: z.array(z.object({
1874
2022
  id: z.number().int(),
1875
2023
  value: z.unknown()
1876
- })).optional()
2024
+ })).optional().describe("Custom field values as { id, value } pairs (field ids come from your Zendesk admin settings).")
1877
2025
  }),
1878
2026
  annotations: {
1879
2027
  readOnlyHint: false,
@@ -1899,7 +2047,7 @@ const createTicketTools = (ctx) => {
1899
2047
  namespace: "tickets",
1900
2048
  readOnly: false,
1901
2049
  title: "Update Zendesk Ticket",
1902
- description: "Update an existing ticket (status, priority, type, assignee, group, subject, tags, custom fields).",
2050
+ description: "Update an existing ticket (status, priority, type, assignee, group, subject, tags, custom fields). Only the fields you pass are changed, and the updated ticket is returned. Setting tags here replaces the whole tag set — use manage_tags to add or remove individual tags without overwriting the rest. This tool does not post replies: use add_public_comment or add_private_note for that. Find the ticket id via search_tickets or list_tickets.",
1903
2051
  inputSchema: z.object({
1904
2052
  ticket_id: z.number().int().describe("Ticket ID"),
1905
2053
  status: z.enum([
@@ -1909,27 +2057,27 @@ const createTicketTools = (ctx) => {
1909
2057
  "hold",
1910
2058
  "solved",
1911
2059
  "closed"
1912
- ]).optional(),
2060
+ ]).optional().describe("New ticket status. One of new, open, pending, hold, solved, closed."),
1913
2061
  priority: z.enum([
1914
2062
  "urgent",
1915
2063
  "high",
1916
2064
  "normal",
1917
2065
  "low"
1918
- ]).optional(),
2066
+ ]).optional().describe("Ticket priority. One of urgent, high, normal, low."),
1919
2067
  type: z.enum([
1920
2068
  "problem",
1921
2069
  "incident",
1922
2070
  "question",
1923
2071
  "task"
1924
- ]).optional(),
1925
- assignee_id: z.number().int().optional(),
1926
- group_id: z.number().int().optional(),
1927
- subject: z.string().optional(),
1928
- tags: z.array(z.string()).optional(),
2072
+ ]).optional().describe("Ticket type. One of problem, incident, question, task."),
2073
+ assignee_id: z.number().int().optional().describe("User id of the agent to assign the ticket to."),
2074
+ group_id: z.number().int().optional().describe("Id of the group to assign the ticket to."),
2075
+ subject: z.string().optional().describe("New ticket subject line."),
2076
+ tags: z.array(z.string()).optional().describe("Replaces the full tag set on the ticket. Use manage_tags for incremental add/remove."),
1929
2077
  custom_fields: z.array(z.object({
1930
2078
  id: z.number().int(),
1931
2079
  value: z.unknown()
1932
- })).optional()
2080
+ })).optional().describe("Custom field values as { id, value } pairs (field ids come from your Zendesk admin settings).")
1933
2081
  }),
1934
2082
  annotations: {
1935
2083
  readOnlyHint: false,
@@ -2190,10 +2338,10 @@ const createUserTools = (ctx) => {
2190
2338
  namespace: "users",
2191
2339
  readOnly: true,
2192
2340
  title: "List Zendesk Organizations",
2193
- description: "List all organizations with pagination.",
2341
+ description: "List all organizations with pagination. Returns the name and id of each organization plus basic fields; results are cursor-paginated. Use get_organization with an id for full details (tags, domains, notes), or search for query-based lookups by name. Organizations group end users and can be referenced when creating or filtering tickets.",
2194
2342
  inputSchema: z.object({
2195
- page_size: z.number().int().min(1).max(100).default(100),
2196
- cursor: z.string().optional()
2343
+ page_size: z.number().int().min(1).max(100).default(100).describe("Organizations per page (1-100, default 100)."),
2344
+ cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page.")
2197
2345
  }),
2198
2346
  annotations: {
2199
2347
  readOnlyHint: true,
@@ -2294,10 +2442,14 @@ const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnaut
2294
2442
  };
2295
2443
  const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized) => {
2296
2444
  const pkg = readPackageInfo();
2445
+ const instructions = buildInstructions(config);
2297
2446
  const server = new McpServer({
2298
2447
  name: pkg.name,
2299
2448
  version: pkg.version
2300
- }, { capabilities: { logging: {} } });
2449
+ }, {
2450
+ capabilities: { logging: {} },
2451
+ ...instructions ? { instructions } : {}
2452
+ });
2301
2453
  logger.attachServer(server);
2302
2454
  const filteredTools = filterTools(createAllTools({
2303
2455
  subdomain: config.subdomain,
@@ -2328,6 +2480,18 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
2328
2480
  registerProxyTool(server, "zendesk", "Zendesk", filteredTools, config.readOnly, onUnauthorized);
2329
2481
  break;
2330
2482
  }
2483
+ if (helpCenterContextEnabled(config)) {
2484
+ const topology = createTopologyProvider(getToken, config.subdomain, onUnauthorized);
2485
+ server.registerResource("help-center-topology", TOPOLOGY_RESOURCE_URI, {
2486
+ title: "Zendesk Help Center topology",
2487
+ description: "Active locales, category → section tree, visibility segments, permission groups, and your role. Read before creating or editing content.",
2488
+ mimeType: "text/markdown"
2489
+ }, async (uri) => ({ contents: [{
2490
+ uri: uri.toString(),
2491
+ mimeType: "text/markdown",
2492
+ text: await topology.read()
2493
+ }] }));
2494
+ }
2331
2495
  logger.info("tools_registered", {
2332
2496
  count: filteredTools.length,
2333
2497
  mode: config.mode