@fruggr/zendesk-mcp-server 2.0.1 → 2.2.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`)
@@ -203,8 +224,11 @@ file per subdomain in your OS config dir —
203
224
  elsewhere; override the path with `ZENDESK_TOKEN_FILE`). It is reused across restarts, so you don't
204
225
  re-authenticate every time the MCP client respawns the server. If the Zendesk
205
226
  OAuth client has token expiration enabled, the stored refresh token is used to
206
- renew access silently; only an expired/invalid refresh token triggers a new
207
- browser sign-in.
227
+ renew access silently **proactively** (the token is refreshed before use when
228
+ it's expired, near expiry, or of unknown age, so the first request after an
229
+ overnight gap never hits a visible auth error) and **periodically** in the
230
+ background so a long-lived, idle session never serves a stale token. Only an
231
+ expired/invalid refresh token triggers a new browser sign-in.
208
232
 
209
233
  > **Port conflict?** If port `27439` is already in use the first tool call returns
210
234
  > a clear error telling you to set `ZENDESK_OAUTH_CALLBACK_PORT` (or
@@ -418,6 +442,8 @@ Options:
418
442
  --namespace <ns> Filter by namespace (repeatable): tickets, help_center, users
419
443
  --tool <name> Filter by tool name (repeatable, forces --mode all)
420
444
  --read-only Only expose read operations
445
+ --no-topology Disable the Help Center structural context
446
+ (instructions + zendesk-hc://topology resource)
421
447
  --log-level <level> debug | info (default) | warn | error
422
448
  --transport <t> stdio (default) | http
423
449
  --host <host> HTTP bind host (default: 0.0.0.0)
package/dist/index.js CHANGED
@@ -221,7 +221,7 @@ const startBrowserAuth = (config, logger = silentLogger) => {
221
221
  const tokenData = await tokenResponse.json();
222
222
  logger.info("oauth_authenticated");
223
223
  res.writeHead(200, { "Content-Type": "text/html" });
224
- res.end("<html><body><h1>Authentication successful!</h1><p>You can close this tab and return to Claude Code.</p><p>This tab will auto-close in <span id=\"t\">10</span>s.</p><script>let n=10;const el=document.getElementById(\"t\");const i=setInterval(()=>{n--;el.textContent=n;if(n<=0){clearInterval(i);window.close();}},1000);<\/script></body></html>");
224
+ res.end("<html><body><h1>Authentication successful!</h1><p>You can close this tab and return to your AI assistant.</p><p>This tab will auto-close in <span id=\"t\">10</span>s.</p><script>let n=10;const el=document.getElementById(\"t\");const i=setInterval(()=>{n--;el.textContent=n;if(n<=0){clearInterval(i);window.close();}},1000);<\/script></body></html>");
225
225
  clearTimeout(authTimeout);
226
226
  callbackServer.close();
227
227
  resolveToken(tokenData);
@@ -432,6 +432,7 @@ const clearToken = (path, logger = silentLogger) => {
432
432
  //#endregion
433
433
  //#region src/auth/token-store.ts
434
434
  const EXPIRY_SKEW_MS = 6e4;
435
+ const SCHEDULED_REFRESH_MS = 14400 * 1e3;
435
436
  const createAuthRequiredError = (authorizeUrl) => Object.assign(/* @__PURE__ */ new Error("Zendesk authentication required. A browser window should have opened for you to sign in. If it did not, open this URL in your browser, then retry your request:\n" + authorizeUrl), {
436
437
  name: "AuthRequiredError",
437
438
  authorizeUrl
@@ -444,16 +445,18 @@ const createTokenStore = (config, logger = silentLogger) => {
444
445
  let authorizeUrl;
445
446
  let starting;
446
447
  let refreshing;
448
+ let probedUnknownExpiry = false;
447
449
  const persist = (t) => saveToken(tokenPath, t, logger);
448
450
  const setToken = (accessToken, refreshToken) => {
449
451
  token = {
450
452
  accessToken,
451
453
  refreshToken
452
454
  };
455
+ probedUnknownExpiry = false;
453
456
  persist(token);
454
457
  };
455
- const isExpired = (t) => typeof t.expiresAt === "number" && Date.now() >= t.expiresAt - EXPIRY_SKEW_MS;
456
- const tryRefresh = async (current) => {
458
+ const needsRefresh = (t) => typeof t.expiresAt === "number" ? Date.now() >= t.expiresAt - EXPIRY_SKEW_MS : t.refreshToken !== void 0 && !probedUnknownExpiry;
459
+ const tryRefresh = async (current, { dropOnFailure = true } = {}) => {
457
460
  if (!current.refreshToken) return void 0;
458
461
  try {
459
462
  const result = await refreshAccessToken({
@@ -466,13 +469,16 @@ const createTokenStore = (config, logger = silentLogger) => {
466
469
  refreshToken: result.refresh_token ?? current.refreshToken,
467
470
  expiresAt: expiryFrom(result.expires_in)
468
471
  };
472
+ probedUnknownExpiry = true;
469
473
  persist(token);
470
474
  logger.info("oauth_token_refreshed_cached");
471
475
  return token.accessToken;
472
476
  } catch (err) {
473
477
  logger.warn("oauth_token_refresh_failed", { error: err instanceof Error ? err.message : String(err) });
474
- token = void 0;
475
- clearToken(tokenPath, logger);
478
+ if (dropOnFailure) {
479
+ token = void 0;
480
+ clearToken(tokenPath, logger);
481
+ }
476
482
  return;
477
483
  }
478
484
  };
@@ -490,6 +496,7 @@ const createTokenStore = (config, logger = silentLogger) => {
490
496
  refreshToken: result.refresh_token,
491
497
  expiresAt: expiryFrom(result.expires_in)
492
498
  };
499
+ probedUnknownExpiry = true;
493
500
  persist(token);
494
501
  logger.info("oauth_token_cached");
495
502
  }).catch((err) => {
@@ -505,7 +512,8 @@ const createTokenStore = (config, logger = silentLogger) => {
505
512
  });
506
513
  };
507
514
  const getToken = async () => {
508
- if (token && !isExpired(token)) {
515
+ if (refreshing) await refreshing;
516
+ if (token && !needsRefresh(token)) {
509
517
  logger.debug("oauth_token_cache_hit");
510
518
  return token.accessToken;
511
519
  }
@@ -533,10 +541,18 @@ const createTokenStore = (config, logger = silentLogger) => {
533
541
  }
534
542
  logger.info("oauth_token_invalidated");
535
543
  };
544
+ const scheduledRefresh = setInterval(() => {
545
+ if (token?.refreshToken && !refreshing) refreshing = tryRefresh(token, { dropOnFailure: false }).finally(() => {
546
+ refreshing = void 0;
547
+ });
548
+ }, SCHEDULED_REFRESH_MS);
549
+ scheduledRefresh.unref?.();
550
+ const dispose = () => clearInterval(scheduledRefresh);
536
551
  return {
537
552
  getToken,
538
553
  setToken,
539
- invalidate
554
+ invalidate,
555
+ dispose
540
556
  };
541
557
  };
542
558
  //#endregion
@@ -566,6 +582,14 @@ const ConfigSchema = z.object({
566
582
  readOnly: z.boolean(),
567
583
  namespaces: z.array(Namespace).optional(),
568
584
  tools: z.array(z.string()).optional(),
585
+ /**
586
+ * Whether to expose the Help Center structural context (the `instructions`
587
+ * blob + the `zendesk-hc://topology` resource). On by default; an operator
588
+ * disables it server-wide with `--no-topology` (e.g. on a very large Help
589
+ * Center, or when the context is unwanted). Only ever active when the
590
+ * `help_center` namespace itself is active.
591
+ */
592
+ topology: z.boolean().default(true),
569
593
  transport: Transport,
570
594
  host: z.string().min(1),
571
595
  port: z.number().int().min(0).max(65535),
@@ -596,6 +620,7 @@ const parseCliArgs = (args) => {
596
620
  result.mode = next;
597
621
  i++;
598
622
  } else if (arg === "--read-only") result.readOnly = true;
623
+ else if (arg === "--no-topology") result.topology = false;
599
624
  else if (arg === "--namespace" && next) {
600
625
  result.namespaces = result.namespaces ?? [];
601
626
  result.namespaces.push(next);
@@ -653,6 +678,7 @@ const loadConfig = (argv = process.argv.slice(2)) => {
653
678
  readOnly: cli.readOnly ?? false,
654
679
  namespaces: cli.namespaces,
655
680
  tools: cli.tools,
681
+ topology: cli.topology ?? true,
656
682
  transport,
657
683
  host,
658
684
  port,
@@ -771,6 +797,246 @@ const helpCenterUpload = async (subdomain, token, path, formData) => {
771
797
  return response.json();
772
798
  };
773
799
  //#endregion
800
+ //#region src/guidance/instructions.ts
801
+ /** Stable URI of the dynamic Help Center topology resource. */
802
+ const TOPOLOGY_RESOURCE_URI = "zendesk-hc://topology";
803
+ /**
804
+ * Whether the Help Center structural context (init instructions + the
805
+ * `zendesk-hc://topology` resource) should be exposed. True only when the
806
+ * feature is enabled (`--no-topology` not set) AND the `help_center` namespace
807
+ * is active (no `--namespace` filter, or one that includes it). Shared by the
808
+ * instructions builder and the resource registration in `server.ts` so both
809
+ * gates stay in sync.
810
+ */
811
+ const helpCenterContextEnabled = (config) => config.topology && (!config.namespaces?.length || config.namespaces.includes("help_center"));
812
+ /**
813
+ * The static `instructions` blob sent on `initialize`. Deliberately short and
814
+ * I/O-free: it must not trigger the lazy OAuth/PKCE flow just to connect, and
815
+ * it stays within a tight token budget. The rich, dynamic topology lives in the
816
+ * pull-only `zendesk-hc://topology` resource referenced here.
817
+ */
818
+ const buildInstructions = (config) => {
819
+ if (!helpCenterContextEnabled(config)) return void 0;
820
+ return [
821
+ `This MCP server is connected to the Zendesk Help Center of "${config.subdomain}".`,
822
+ "",
823
+ `Before creating or editing Help Center content, read the resource ${TOPOLOGY_RESOURCE_URI}.`,
824
+ "It describes the active locales (and the default one), the category → section tree with IDs,",
825
+ "the visibility user segments, the permission groups, and your current role.",
826
+ "Prefer the IDs from that resource (section_id, permission_group_id, user_segment_id, locale)",
827
+ "over guessing from names."
828
+ ].join("\n");
829
+ };
830
+ //#endregion
831
+ //#region src/utils/formatting.ts
832
+ const truncateIfNeeded = (text) => {
833
+ if (text.length <= 25e3) return text;
834
+ return `${text.slice(0, CHARACTER_LIMIT)}\n\n--- Response truncated (${text.length} chars, limit ${CHARACTER_LIMIT}). Use pagination or filters to reduce results. ---`;
835
+ };
836
+ const formatPagination = (meta) => {
837
+ const parts = [`Results: ${meta.count}`];
838
+ if (meta.has_more) parts.push(`More available (cursor: ${meta.after_cursor})`);
839
+ return parts.join(" | ");
840
+ };
841
+ const formatTicket = (ticket) => [
842
+ `## Ticket #${ticket.id}: ${ticket.subject}`,
843
+ `- **Status**: ${ticket.status} | **Priority**: ${ticket.priority ?? "none"} | **Type**: ${ticket.type ?? "none"}`,
844
+ `- **Requester**: ${ticket.requester_id} | **Assignee**: ${ticket.assignee_id ?? "unassigned"}`,
845
+ `- **Tags**: ${ticket.tags.length > 0 ? ticket.tags.join(", ") : "none"}`,
846
+ `- **Created**: ${ticket.created_at} | **Updated**: ${ticket.updated_at}`,
847
+ ticket.description ? `\n${ticket.description}` : ""
848
+ ].filter(Boolean).join("\n");
849
+ const formatComment = (comment) => {
850
+ const lines = [`### ${comment.public ? "Public comment" : "Internal note"} by ${comment.author_id}`, `*${comment.created_at}*`];
851
+ if (comment.attachments?.length) {
852
+ const summary = comment.attachments.map((a) => `#${a.id} (${a.content_type})`).join(", ");
853
+ lines.push(`Attachments: ${summary}`);
854
+ }
855
+ lines.push("", comment.body);
856
+ return lines.join("\n");
857
+ };
858
+ const formatUser = (user) => [
859
+ `## ${user.name} (${user.id})`,
860
+ `- **Email**: ${user.email}`,
861
+ `- **Role**: ${user.role}`,
862
+ user.role_type != null ? `- **Role type**: ${user.role_type}` : "",
863
+ `- **Active**: ${user.active}`,
864
+ user.organization_id ? `- **Organization**: ${user.organization_id}` : ""
865
+ ].filter(Boolean).join("\n");
866
+ const formatOrganization = (org) => [
867
+ `## ${org.name} (${org.id})`,
868
+ org.details ? `- **Details**: ${org.details}` : "",
869
+ org.domain_names.length > 0 ? `- **Domains**: ${org.domain_names.join(", ")}` : "",
870
+ org.tags.length > 0 ? `- **Tags**: ${org.tags.join(", ")}` : ""
871
+ ].filter(Boolean).join("\n");
872
+ const formatArticleSummary = (article) => [
873
+ `## ${article.title} (${article.id})`,
874
+ `- **Locale**: ${article.locale} | **Source locale**: ${article.source_locale}`,
875
+ `- **Section**: ${article.section_id} | **Draft**: ${article.draft}`,
876
+ typeof article.position === "number" ? `- **Position**: ${article.position}` : "",
877
+ article.label_names.length > 0 ? `- **Labels**: ${article.label_names.join(", ")}` : "",
878
+ `- **Created**: ${article.created_at} | **Updated**: ${article.updated_at}`
879
+ ].filter(Boolean).join("\n");
880
+ const formatArticle = (article) => [
881
+ formatArticleSummary(article),
882
+ "",
883
+ article.body
884
+ ].join("\n");
885
+ const formatTranslationSummary = (translation) => [
886
+ `## Translation: ${translation.locale} (${translation.id})`,
887
+ `- **Title**: ${translation.title}`,
888
+ `- **Draft**: ${translation.draft}`,
889
+ `- **Updated**: ${translation.updated_at}`
890
+ ].join("\n");
891
+ const formatTranslation = (translation) => [
892
+ formatTranslationSummary(translation),
893
+ "",
894
+ translation.body
895
+ ].join("\n");
896
+ const formatCategory = (category) => `- **${category.name}** (${category.id}) — ${category.description || "No description"}`;
897
+ const formatSection = (section) => `- **${section.name}** (${section.id}) — Category: ${section.category_id} — ${section.description || "No description"}`;
898
+ const formatPermissionGroup = (group) => `- **${group.name}** (${group.id})${group.built_in ? " — Built-in" : ""}`;
899
+ const formatContentTag = (tag) => `- **${tag.name}** (${tag.id})`;
900
+ const formatLabel = (label) => `- **${label.name}** (${label.id})`;
901
+ const formatUserSegment = (segment) => `- **${segment.name}** (${segment.id}) — ${segment.user_type}${segment.built_in ? " — Built-in" : ""}`;
902
+ const formatAttachment = (attachment) => `- **${attachment.file_name}** (${attachment.id}) — ${attachment.content_type} — ${attachment.size} bytes`;
903
+ const formatList = (items, formatter, meta) => {
904
+ return truncateIfNeeded([meta ? formatPagination(meta) : "", items.map(formatter).join("\n\n")].filter(Boolean).join("\n\n"));
905
+ };
906
+ //#endregion
907
+ //#region src/utils/pagination.ts
908
+ const buildCursorParams = (pageSize, cursor) => {
909
+ const params = { "page[size]": String(pageSize) };
910
+ if (cursor) params["page[after]"] = cursor;
911
+ return params;
912
+ };
913
+ const buildOffsetParams = (perPage, page) => {
914
+ const params = { per_page: String(perPage) };
915
+ if (page && page > 1) params["page"] = String(page);
916
+ return params;
917
+ };
918
+ const extractPaginationMeta = (response) => ({
919
+ has_more: response.meta?.has_more ?? response.next_page != null,
920
+ after_cursor: response.meta?.after_cursor ?? null,
921
+ count: response.count ?? 0
922
+ });
923
+ const extractSearchPaginationMeta = (response, perPage, page) => {
924
+ const count = response.count ?? 0;
925
+ const has_more = count > page * perPage;
926
+ return {
927
+ has_more,
928
+ after_cursor: has_more ? String(page + 1) : null,
929
+ count
930
+ };
931
+ };
932
+ //#endregion
933
+ //#region src/guidance/topology.ts
934
+ /**
935
+ * Fetch the structural topology with the CALLER'S token, so the result respects
936
+ * that user's read permissions (no privileged shared credential). Categories
937
+ * and sections are each capped at one max-size page; `sectionsHasMore` /
938
+ * `categoriesHasMore` signal a Help Center too large to enumerate inline.
939
+ */
940
+ const fetchTopology = async (subdomain, token) => {
941
+ const pageParams = { "page[size]": String(100) };
942
+ const [locales, categoriesRes, sectionsRes, segmentsRes, permsRes, meRes] = await Promise.all([
943
+ helpCenterGet(subdomain, token, "/locales"),
944
+ helpCenterGet(subdomain, token, "/categories", pageParams),
945
+ helpCenterGet(subdomain, token, "/sections", pageParams),
946
+ helpCenterGet(subdomain, token, "/user_segments"),
947
+ zendeskGet(subdomain, token, "/guide/permission_groups"),
948
+ zendeskGet(subdomain, token, "/users/me")
949
+ ]);
950
+ return {
951
+ subdomain,
952
+ locales,
953
+ categories: categoriesRes.categories ?? [],
954
+ sections: sectionsRes.sections ?? [],
955
+ sectionsHasMore: extractPaginationMeta(sectionsRes).has_more,
956
+ categoriesHasMore: extractPaginationMeta(categoriesRes).has_more,
957
+ userSegments: segmentsRes.user_segments ?? [],
958
+ permissionGroups: permsRes.permission_groups ?? [],
959
+ currentUser: meRes.user
960
+ };
961
+ };
962
+ const renderTree = (data) => {
963
+ if (data.categoriesHasMore || data.sectionsHasMore) {
964
+ const reasons = [];
965
+ if (data.categoriesHasMore) reasons.push(`more than 100 categories`);
966
+ if (data.sectionsHasMore) reasons.push(`more than 100 sections`);
967
+ return [
968
+ `Large Help Center (${reasons.join(" and ")}) — the full tree is omitted to stay concise.`,
969
+ data.categoriesHasMore ? "Categories (partial list):" : "Categories:",
970
+ ...data.categories.map(formatCategory),
971
+ "",
972
+ ...data.categoriesHasMore ? ["Use the `list_categories` tool to enumerate all categories."] : [],
973
+ "Use the `list_sections` tool (filtered by `category_id`) to enumerate sections under a category."
974
+ ];
975
+ }
976
+ const byCategory = /* @__PURE__ */ new Map();
977
+ for (const section of data.sections) {
978
+ const list = byCategory.get(section.category_id) ?? [];
979
+ list.push(section);
980
+ byCategory.set(section.category_id, list);
981
+ }
982
+ const lines = [];
983
+ for (const category of data.categories) {
984
+ lines.push(formatCategory(category));
985
+ for (const section of byCategory.get(category.id) ?? []) lines.push(` ${formatSection(section)}`);
986
+ }
987
+ return lines.length ? lines : ["_(no categories)_"];
988
+ };
989
+ /** Render the topology as a compact Markdown document for the LLM context. */
990
+ const formatTopology = (data) => {
991
+ return truncateIfNeeded([
992
+ `# Zendesk Help Center topology — ${data.subdomain}`,
993
+ "",
994
+ `**Your access**: ${data.currentUser.name} (id ${data.currentUser.id}), role "${data.currentUser.role}".`,
995
+ "",
996
+ "## Locales",
997
+ `- Default: ${data.locales.default_locale}`,
998
+ `- Active: ${data.locales.locales.join(", ")}`,
999
+ "",
1000
+ "## Categories → sections",
1001
+ ...renderTree(data),
1002
+ "",
1003
+ "## Visibility (user segments)",
1004
+ ...data.userSegments.length ? data.userSegments.map(formatUserSegment) : ["_(none)_"],
1005
+ "",
1006
+ "## Permission groups",
1007
+ ...data.permissionGroups.length ? data.permissionGroups.map(formatPermissionGroup) : ["_(none)_"]
1008
+ ].join("\n"));
1009
+ };
1010
+ /**
1011
+ * Build a topology provider holding a memoized-promise cache (TTL
1012
+ * `TOPOLOGY_TTL_MS`). In HTTP mode `createMcpServer` — and therefore this
1013
+ * provider — is instantiated PER SESSION, so this cache is per-session /
1014
+ * per-caller: it must NOT be hoisted to module scope, or one tenant's structure
1015
+ * would leak to another. The promise (not the value) is cached to coalesce
1016
+ * concurrent reads; failures are evicted so the next read retries with a fresh
1017
+ * token. A 401 notifies `onUnauthorized` (stdio OAuth only) to invalidate the
1018
+ * stale token, mirroring the tool dispatch path in `server.ts`.
1019
+ */
1020
+ const createTopologyProvider = (getToken, subdomain, onUnauthorized) => {
1021
+ let cached;
1022
+ return { read() {
1023
+ const now = Date.now();
1024
+ if (cached && now - cached.at < 3e5) return cached.promise;
1025
+ const promise = (async () => {
1026
+ return formatTopology(await fetchTopology(subdomain, await getToken()));
1027
+ })().catch((err) => {
1028
+ cached = void 0;
1029
+ if (onUnauthorized && err instanceof ZendeskApiError && err.status === 401) onUnauthorized();
1030
+ throw err;
1031
+ });
1032
+ cached = {
1033
+ at: now,
1034
+ promise
1035
+ };
1036
+ return promise;
1037
+ } };
1038
+ };
1039
+ //#endregion
774
1040
  //#region src/routing/registry.ts
775
1041
  const filterTools = (allTools, options) => allTools.filter((tool) => {
776
1042
  if (options.readOnly && !tool.readOnly) return false;
@@ -883,108 +1149,6 @@ const markdownToHtml = (markdown) => {
883
1149
  return String(mdToHtmlProcessor.processSync(markdown));
884
1150
  };
885
1151
  //#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
1152
  //#region src/tools/help-center.ts
989
1153
  const largeArticleHint = (body, sectionCount) => {
990
1154
  if (body.length < 3e3 && sectionCount < 4) return null;
@@ -2228,6 +2392,13 @@ const createAllTools = (ctx) => [
2228
2392
  * refreshes/re-authenticates instead of replaying a revoked token. The callback
2229
2393
  * is omitted only where there is nothing to invalidate (e.g. HTTP per-session
2230
2394
  * bearer, owned by the client).
2395
+ *
2396
+ * Client-visible behaviour on an in-flight revocation: the 401 is a *backstop*,
2397
+ * not a transparent retry. The current call still surfaces the error; recovery
2398
+ * happens on the *next* call, whose `getToken` sees the invalidated token and
2399
+ * silently refreshes (or falls back to browser re-auth if the refresh token is
2400
+ * also dead). Proactive refresh keeps this path rare — it only fires when a
2401
+ * token is revoked between the pre-call refresh check and the request.
2231
2402
  */
2232
2403
  const runHandler = async (def, params, onUnauthorized) => {
2233
2404
  try {
@@ -2294,10 +2465,14 @@ const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnaut
2294
2465
  };
2295
2466
  const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized) => {
2296
2467
  const pkg = readPackageInfo();
2468
+ const instructions = buildInstructions(config);
2297
2469
  const server = new McpServer({
2298
2470
  name: pkg.name,
2299
2471
  version: pkg.version
2300
- }, { capabilities: { logging: {} } });
2472
+ }, {
2473
+ capabilities: { logging: {} },
2474
+ ...instructions ? { instructions } : {}
2475
+ });
2301
2476
  logger.attachServer(server);
2302
2477
  const filteredTools = filterTools(createAllTools({
2303
2478
  subdomain: config.subdomain,
@@ -2328,6 +2503,18 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
2328
2503
  registerProxyTool(server, "zendesk", "Zendesk", filteredTools, config.readOnly, onUnauthorized);
2329
2504
  break;
2330
2505
  }
2506
+ if (helpCenterContextEnabled(config)) {
2507
+ const topology = createTopologyProvider(getToken, config.subdomain, onUnauthorized);
2508
+ server.registerResource("help-center-topology", TOPOLOGY_RESOURCE_URI, {
2509
+ title: "Zendesk Help Center topology",
2510
+ description: "Active locales, category → section tree, visibility segments, permission groups, and your role. Read before creating or editing content.",
2511
+ mimeType: "text/markdown"
2512
+ }, async (uri) => ({ contents: [{
2513
+ uri: uri.toString(),
2514
+ mimeType: "text/markdown",
2515
+ text: await topology.read()
2516
+ }] }));
2517
+ }
2331
2518
  logger.info("tools_registered", {
2332
2519
  count: filteredTools.length,
2333
2520
  mode: config.mode