@fruggr/zendesk-mcp-server 2.22.3 → 2.24.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.
Files changed (3) hide show
  1. package/README.md +4 -4
  2. package/dist/index.js +177 -66
  3. package/package.json +8 -6
package/README.md CHANGED
@@ -83,7 +83,7 @@ differently.
83
83
  - Two deployment shapes, same auth story. Run it on your laptop as a stdio MCP
84
84
  server, or deploy it as a private remote MCP server reached over HTTP, with one
85
85
  Zendesk session per request and each client carrying its own user's token.
86
- - A lean stack: the official `@modelcontextprotocol/sdk` plus `zod`, speaking to
86
+ - A lean stack: the official MCP TypeScript SDK (`@modelcontextprotocol/server`) plus `zod`, speaking to
87
87
  the Zendesk Support and Help Center (Guide) APIs.
88
88
 
89
89
  Look elsewhere when:
@@ -139,7 +139,7 @@ Signing in needs a Zendesk OAuth client, so register one first (next section).
139
139
  2. Create a **public** client:
140
140
  - **Identifier**: `<your-subdomain>_zendesk` (or set `ZENDESK_OAUTH_CLIENT_ID`)
141
141
  - **Redirect URL**: `http://localhost:27439/callback` (change the port to match
142
- `ZENDESK_OAUTH_CALLBACK_PORT` / `--callback-port` if you override it; Zendesk
142
+ `OAUTH_CALLBACK_PORT` / `--callback-port` if you override it; Zendesk
143
143
  accepts several redirect URLs, one per line)
144
144
 
145
145
  If the client restricts its **allowed scopes**, it needs `read` — plus `write`
@@ -151,7 +151,7 @@ window and returns the authorize URL in a tool message. The call does not block
151
151
  waiting for sign-in, so authenticate in the browser and then retry the request.
152
152
  The token is persisted to an owner-only file and reused across restarts, so you
153
153
  don't authenticate again every time your MCP client respawns the server (path
154
- and overrides: [`ZENDESK_TOKEN_FILE`](docs/configuration.md#zendesk_token_file)).
154
+ and overrides: [`OAUTH_TOKEN_FILE`](docs/configuration.md#oauth_token_file)).
155
155
  Several instances can run side by side — a read-write one and a `--read-only`
156
156
  one, say — without trading credentials.
157
157
 
@@ -326,7 +326,7 @@ to turn each piece off: **[docs/help-center-context.md](docs/help-center-context
326
326
 
327
327
  The complete reference for the CLI flags (`--mode`, `--namespace`,
328
328
  `--read-only`, `--transport`, `--public-url`, and so on) and the environment
329
- variables (`ZENDESK_SUBDOMAIN`, `ZENDESK_TOKEN_FILE`, `PUBLIC_URL`, the
329
+ variables (`ZENDESK_SUBDOMAIN`, `OAUTH_TOKEN_FILE`, `PUBLIC_URL`, the
330
330
  attachment-vision caps) lives in
331
331
  **[docs/configuration.md](docs/configuration.md)**. Every variable has its own
332
332
  anchor, so you can deep-link a specific setting.
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { dirname, join } from "node:path";
9
9
  import { fileURLToPath, pathToFileURL } from "node:url";
10
10
  import { parseArgs } from "node:util";
11
11
  import * as z from "zod/v4";
12
- import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server";
13
13
  import * as cheerio from "cheerio";
14
14
  import { toHtml } from "hast-util-to-html";
15
15
  import rehypeParse from "rehype-parse";
@@ -21,8 +21,8 @@ import remarkParse from "remark-parse";
21
21
  import remarkRehype from "remark-rehype";
22
22
  import remarkStringify from "remark-stringify";
23
23
  import { unified } from "unified";
24
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
25
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
24
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
25
+ import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
26
26
  //#region src/utils/logger.ts
27
27
  const SEVERITY = {
28
28
  debug: 0,
@@ -68,7 +68,7 @@ const sanitise = (fields) => {
68
68
  };
69
69
  const renderValue = (value) => {
70
70
  if (typeof value === "string") return value;
71
- if (typeof value === "number" || typeof value === "boolean" || value === null) return String(value);
71
+ if (typeof value === "number") return String(value);
72
72
  try {
73
73
  return JSON.stringify(value);
74
74
  } catch {
@@ -118,25 +118,74 @@ const createLogger = (level) => {
118
118
  };
119
119
  };
120
120
  //#endregion
121
+ //#region src/utils/env.ts
122
+ const LEGACY_NAMES = /* @__PURE__ */ new Map([
123
+ ["OAUTH_TOKEN_FILE", "ZENDESK_TOKEN_FILE"],
124
+ ["OAUTH_CALLBACK_PORT", "ZENDESK_OAUTH_CALLBACK_PORT"],
125
+ ["LISTEN_HOST", "HOST"],
126
+ ["RESPONSE_CHARACTER_LIMIT", "ZENDESK_CHARACTER_LIMIT"],
127
+ ["RESPONSE_MAX_BYTES", "ZENDESK_MAX_RESPONSE_BYTES"],
128
+ ["ATTACHMENT_MAX_BYTES", "ZENDESK_MAX_ATTACHMENT_BYTES"],
129
+ ["EMBEDDED_IMAGES_MAX", "ZENDESK_MAX_EMBEDDED_IMAGES"],
130
+ ["COMMENT_MAX_PAGES", "ZENDESK_MAX_COMMENT_PAGES"],
131
+ ["TICKET_FIELD_SCAN_MAX_PAGES", "ZENDESK_TICKET_FIELD_SCAN_MAX_PAGES"],
132
+ ["ARTICLE_RESOURCES_SCAN_MAX_PAGES", "ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES"],
133
+ ["REORDER_CONFIRM_THRESHOLD", "ZENDESK_REORDER_CONFIRM_THRESHOLD"]
134
+ ]);
135
+ const warned = /* @__PURE__ */ new Set();
136
+ const warnDeprecated = (legacy, current) => {
137
+ if (warned.has(legacy)) return;
138
+ warned.add(legacy);
139
+ try {
140
+ console.error(`[zendesk-mcp] [warn] deprecated_env_var name=${legacy} replacement=${current} removal=3.0.0`);
141
+ } catch {}
142
+ };
143
+ /**
144
+ * Reads `name`, falling back to the legacy name it replaced. `name` in the result
145
+ * is the variable the value came from, so an error about it names what the
146
+ * operator actually set.
147
+ */
148
+ const readEnv = (name) => {
149
+ const value = process.env[name];
150
+ const legacy = LEGACY_NAMES.get(name);
151
+ if (legacy === void 0) return {
152
+ name,
153
+ value
154
+ };
155
+ const legacyValue = process.env[legacy];
156
+ if (legacyValue === void 0) return {
157
+ name,
158
+ value
159
+ };
160
+ warnDeprecated(legacy, name);
161
+ return value === void 0 ? {
162
+ name: legacy,
163
+ value: legacyValue
164
+ } : {
165
+ name,
166
+ value
167
+ };
168
+ };
169
+ //#endregion
121
170
  //#region src/constants.ts
122
171
  const positiveIntEnv = (name, fallback) => {
123
- const raw = process.env[name];
172
+ const raw = readEnv(name).value;
124
173
  if (raw === void 0 || raw.trim() === "") return fallback;
125
174
  const parsed = Number(raw);
126
175
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
127
176
  };
128
- const CHARACTER_LIMIT = positiveIntEnv("ZENDESK_CHARACTER_LIMIT", 25e3);
129
- const ARTICLE_RESOURCES_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES", 20);
130
- const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5242880);
131
- const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("ZENDESK_MAX_EMBEDDED_IMAGES", 10);
177
+ const CHARACTER_LIMIT = positiveIntEnv("RESPONSE_CHARACTER_LIMIT", 25e3);
178
+ const ARTICLE_RESOURCES_SCAN_MAX_PAGES = positiveIntEnv("ARTICLE_RESOURCES_SCAN_MAX_PAGES", 20);
179
+ const MAX_ATTACHMENT_BYTES = positiveIntEnv("ATTACHMENT_MAX_BYTES", 5242880);
180
+ const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("EMBEDDED_IMAGES_MAX", 10);
132
181
  const STDIO_MAX_MESSAGE_BYTES = 10485760;
133
182
  const MESSAGE_CONTENT_BUDGET_BYTES = 10420224;
134
- const MAX_RESPONSE_BYTES = Math.min(positiveIntEnv("ZENDESK_MAX_RESPONSE_BYTES", MESSAGE_CONTENT_BUDGET_BYTES), MESSAGE_CONTENT_BUDGET_BYTES);
183
+ const MAX_RESPONSE_BYTES = Math.min(positiveIntEnv("RESPONSE_MAX_BYTES", MESSAGE_CONTENT_BUDGET_BYTES), MESSAGE_CONTENT_BUDGET_BYTES);
135
184
  const MAX_BASE64_INPUT_CHARS = MESSAGE_CONTENT_BUDGET_BYTES;
136
185
  const MAX_BASE64_INPUT_MB = Number.parseFloat((MAX_BASE64_INPUT_CHARS / 4 * 3 / 1048576).toFixed(2));
137
- const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
138
- const TICKET_FIELD_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_TICKET_FIELD_SCAN_MAX_PAGES", 10);
139
- const REORDER_CONFIRM_THRESHOLD = positiveIntEnv("ZENDESK_REORDER_CONFIRM_THRESHOLD", 20);
186
+ const MAX_COMMENT_PAGES = positiveIntEnv("COMMENT_MAX_PAGES", 10);
187
+ const TICKET_FIELD_SCAN_MAX_PAGES = positiveIntEnv("TICKET_FIELD_SCAN_MAX_PAGES", 10);
188
+ const REORDER_CONFIRM_THRESHOLD = positiveIntEnv("REORDER_CONFIRM_THRESHOLD", 20);
140
189
  const getBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2`;
141
190
  const getHelpCenterBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2/help_center`;
142
191
  const getOAuthUrls = (subdomain) => ({
@@ -178,7 +227,7 @@ const supportedScopes = (readOnly) => scopeTokens(requestedScope(readOnly));
178
227
  * Whether a grant still covers what this process needs: a flat subset test.
179
228
  * Coverage, not equality, so a token Zendesk granted wider than requested stays
180
229
  * usable. A non-string `granted` is a pre-#283 record, i.e. `read write` — only
181
- * reachable now through a `ZENDESK_TOKEN_FILE` aimed at one by hand, since the
230
+ * reachable now through a `OAUTH_TOKEN_FILE` aimed at one by hand, since the
182
231
  * default layout no longer names those files. No scope hierarchy: granular
183
232
  * scopes (#284) replace this.
184
233
  */
@@ -207,7 +256,7 @@ const detectWsl = () => {
207
256
  * (`docs/decisions/token-file-keying.md`). The `(EADDRINUSE)` marker and `code`
208
257
  * are kept for diagnostics/tests.
209
258
  */
210
- const callbackPortInUseError = (port, cause) => Object.assign(/* @__PURE__ */ new Error(`Cannot start the Zendesk OAuth sign-in: local callback port ${port} is already in use. Another instance of this server is most likely signing in right now: finish that browser window, then retry. If an unrelated program holds the port, set ZENDESK_OAUTH_CALLBACK_PORT (or --callback-port) to a free port, then register http://localhost:<port>/callback as a redirect URL in your Zendesk OAuth client. (EADDRINUSE)`), {
259
+ const callbackPortInUseError = (port, cause) => Object.assign(/* @__PURE__ */ new Error(`Cannot start the Zendesk OAuth sign-in: local callback port ${port} is already in use. Another instance of this server is most likely signing in right now: finish that browser window, then retry. If an unrelated program holds the port, set OAUTH_CALLBACK_PORT (or --callback-port) to a free port, then register http://localhost:<port>/callback as a redirect URL in your Zendesk OAuth client. (EADDRINUSE)`), {
211
260
  code: "EADDRINUSE",
212
261
  cause
213
262
  });
@@ -500,11 +549,12 @@ const keyDigest = (key) => createHash("sha256").update(JSON.stringify([
500
549
  * their own record instead of clobbering a shared file. Why that triple, and
501
550
  * why no migration from the old subdomain-only layout:
502
551
  * `docs/decisions/token-file-keying.md`.
503
- * `ZENDESK_TOKEN_FILE` overrides with an explicit path — the way to separate
552
+ * `OAUTH_TOKEN_FILE` overrides with an explicit path — the way to separate
504
553
  * two Zendesk accounts that share a subdomain, client and scope.
505
554
  */
506
555
  const resolveTokenPath = (key) => {
507
- const override = process.env["ZENDESK_TOKEN_FILE"];
556
+ const { name, value: override } = readEnv("OAUTH_TOKEN_FILE");
557
+ if (override === "") throw new Error(`Empty ${name}. Set it to a value, or unset it entirely.`);
508
558
  if (override) return override;
509
559
  const readable = [
510
560
  key.subdomain,
@@ -791,11 +841,7 @@ const ConfigSchema = z.object({
791
841
  abort: true
792
842
  }).refine((scheme) => {
793
843
  const uri = `${scheme}://topology`;
794
- try {
795
- return new URL(uri).toString() === uri;
796
- } catch {
797
- return false;
798
- }
844
+ return new URL(uri).toString() === uri;
799
845
  }, { message: "Invalid HC_RESOURCE_SCHEME / --hc-resource-scheme value. WHATWG-special schemes (http, https, ws, wss, ftp, file) do not survive URL normalization and would make the resource unreadable; pick a custom scheme such as \"wiki\"." }).default("zendesk-hc"),
800
846
  /**
801
847
  * Dev-only (stdio): expose the `reload_tools` tool, which re-imports the tool
@@ -832,11 +878,15 @@ const parsePort = (raw, label) => {
832
878
  if (!DIGITS_ONLY.test(raw)) throw new Error(`Invalid ${label} value. Expected an integer 0-65535.`);
833
879
  return Number(raw);
834
880
  };
835
- const parsePortEnv = (raw, label) => raw === void 0 ? void 0 : parsePort(raw, label);
836
- const requireNonEmptyEnv = (name) => {
837
- const raw = process.env[name];
838
- if (raw === "") throw new Error(`Empty ${name}. Set it to a value, or unset it entirely.`);
839
- return raw;
881
+ const readNonEmptyEnv = (name) => {
882
+ const env = readEnv(name);
883
+ if (env.value === "") throw new Error(`Empty ${env.name}. Set it to a value, or unset it entirely.`);
884
+ return env;
885
+ };
886
+ const requireNonEmptyEnv = (name) => readNonEmptyEnv(name).value;
887
+ const portEnv = (name) => {
888
+ const env = readNonEmptyEnv(name);
889
+ return env.value === void 0 ? void 0 : parsePort(env.value, env.name);
840
890
  };
841
891
  const CLI_OPTIONS = {
842
892
  mode: { type: "string" },
@@ -910,8 +960,8 @@ const resolveTransportSettings = (cli) => {
910
960
  const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
911
961
  return {
912
962
  transport: cli.transport ?? requireNonEmptyEnv("TRANSPORT") ?? "stdio",
913
- host: cli.host ?? requireNonEmptyEnv("HOST") ?? "0.0.0.0",
914
- port: cli.port ?? parsePortEnv(requireNonEmptyEnv("PORT"), "PORT") ?? 3e3,
963
+ host: cli.host ?? requireNonEmptyEnv("LISTEN_HOST") ?? "0.0.0.0",
964
+ port: cli.port ?? portEnv("PORT") ?? 3e3,
915
965
  publicUrl: cli.publicUrl ?? requireNonEmptyEnv("PUBLIC_URL"),
916
966
  corsOrigins: [...cli.corsOrigins ?? [], ...corsFromEnv]
917
967
  };
@@ -922,7 +972,7 @@ const loadConfig = (argv = process.argv.slice(2)) => {
922
972
  const oauthClientId = requireNonEmptyEnv("ZENDESK_OAUTH_CLIENT_ID") ?? `${subdomain}_zendesk`;
923
973
  const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
924
974
  const namespaces = cli.namespaces ?? (cli.tools?.length ? [...Namespace.options] : void 0);
925
- const callbackPort = cli.callbackPort ?? parsePortEnv(requireNonEmptyEnv("ZENDESK_OAUTH_CALLBACK_PORT"), "ZENDESK_OAUTH_CALLBACK_PORT");
975
+ const callbackPort = cli.callbackPort ?? portEnv("OAUTH_CALLBACK_PORT");
926
976
  const hcResourceScheme = cli.hcResourceScheme ?? requireNonEmptyEnv("HC_RESOURCE_SCHEME");
927
977
  return ConfigSchema.parse({
928
978
  subdomain,
@@ -1331,8 +1381,7 @@ const htmlToMdProcessor = unified().use(rehypeParse, { fragment: true }).use(reh
1331
1381
  pre: keepAsHtml
1332
1382
  } }).use(remarkGfm).use(remarkStringify, {
1333
1383
  bullet: "-",
1334
- emphasis: "_",
1335
- fences: true
1384
+ emphasis: "_"
1336
1385
  });
1337
1386
  const mdToHtmlProcessor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw).use(rehypeStringify);
1338
1387
  const htmlToMarkdown = (html) => {
@@ -1450,6 +1499,12 @@ const withName = (id, names) => {
1450
1499
  const name = names.get(n);
1451
1500
  return name ? `${name} (${id})` : String(id);
1452
1501
  };
1502
+ const formatSubscribersBlock = (ticket, names) => {
1503
+ const line = (label, ids) => `- **${label}**: ${ids.length > 0 ? ids.map((id) => withName(id, names)).join(", ") : "none"}`;
1504
+ const rows = [Array.isArray(ticket.follower_ids) ? line("Followers", ticket.follower_ids) : "", Array.isArray(ticket.email_cc_ids) ? line("Email CCs", ticket.email_cc_ids) : ""].filter(Boolean);
1505
+ if (rows.length === 0) return "";
1506
+ return `\n\n${["### Subscribers", ...rows].join("\n")}`;
1507
+ };
1453
1508
  const formatComment = (comment, authors) => {
1454
1509
  const lines = [`### ${comment.public ? "Public comment" : "Internal note"} (id ${comment.id}) by ${withName(comment.author_id, authors ?? /* @__PURE__ */ new Map())}`, `*${comment.created_at}*`];
1455
1510
  if (comment.attachments?.length) {
@@ -2042,9 +2097,9 @@ const NAMESPACE_LABELS = {
2042
2097
  //#endregion
2043
2098
  //#region src/utils/article-order.ts
2044
2099
  const hasPositionInversion = (order) => {
2045
- for (let i = 0; i < order.length - 1; i += 1) {
2046
- const here = order[i];
2047
- const next = order[i + 1];
2100
+ for (let i = 1; i < order.length; i += 1) {
2101
+ const here = order[i - 1];
2102
+ const next = order[i];
2048
2103
  if (here && next && here.position > next.position) return true;
2049
2104
  }
2050
2105
  return false;
@@ -2296,7 +2351,7 @@ const localePrefix = (locale) => locale ? `/${locale}` : "";
2296
2351
  const articleListPath = (sectionId, locale) => `${localePrefix(locale)}${sectionId ? `/sections/${sectionId}` : ""}/articles`;
2297
2352
  const sectionListPath = (categoryId, locale) => `${localePrefix(locale)}${categoryId ? `/categories/${categoryId}` : ""}/sections`;
2298
2353
  const scanCostNote = (truncated, pagesScanned, cost) => {
2299
- if (truncated) return `\n\n_Note: the scan hit its ${ARTICLE_RESOURCES_SCAN_MAX_PAGES}-page cap (${cost}), so promoted articles deeper in the catalog may be missing. This call is costly on this Help Center — avoid repeating it; raise ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES to widen coverage._`;
2354
+ if (truncated) return `\n\n_Note: the scan hit its ${ARTICLE_RESOURCES_SCAN_MAX_PAGES}-page cap (${cost}), so promoted articles deeper in the catalog may be missing. This call is costly on this Help Center — avoid repeating it; raise ARTICLE_RESOURCES_SCAN_MAX_PAGES to widen coverage._`;
2300
2355
  if (pagesScanned > 1) return `\n\n_Note: this scan cost ${cost}; this tool performs a fresh scan every call (no caching), so avoid calling it again right away._`;
2301
2356
  return "";
2302
2357
  };
@@ -2521,7 +2576,7 @@ const createHelpCenterTools = (ctx) => {
2521
2576
  namespace: "help_center",
2522
2577
  readOnly: true,
2523
2578
  title: "List Promoted Help Center Articles",
2524
- description: "List the promoted (\"featured\") Help Center articles — the small, editorially-curated set surfaced at the top of their sections. Returns metadata only (no body); use get_article for full content. COST: the Help Center API has no server-side promoted filter, so this scans article pages (one Zendesk API request per page, up to ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES, default 20) and filters client-side — potentially costly on a large Help Center. Each call performs a fresh, uncached scan, so avoid calling it repeatedly. On a very large Help Center some promoted articles may be omitted, and both the omission and the number of pages scanned are flagged in the output. Lists the default locale. To promote or unpromote an article, use update_article with `promoted` (requires Help Center admin / Guide admin rights).",
2579
+ description: "List the promoted (\"featured\") Help Center articles — the small, editorially-curated set surfaced at the top of their sections. Returns metadata only (no body); use get_article for full content. COST: the Help Center API has no server-side promoted filter, so this scans article pages (one Zendesk API request per page, up to ARTICLE_RESOURCES_SCAN_MAX_PAGES, default 20) and filters client-side — potentially costly on a large Help Center. Each call performs a fresh, uncached scan, so avoid calling it repeatedly. On a very large Help Center some promoted articles may be omitted, and both the omission and the number of pages scanned are flagged in the output. Lists the default locale. To promote or unpromote an article, use update_article with `promoted` (requires Help Center admin / Guide admin rights).",
2525
2580
  inputSchema: z.object({}),
2526
2581
  annotations: {
2527
2582
  readOnlyHint: true,
@@ -2899,7 +2954,7 @@ const createHelpCenterTools = (ctx) => {
2899
2954
  ]).describe("Where to move the article relative to its section siblings: \"top\" (becomes first), \"bottom\" (becomes last), or \"before\"/\"after\" a specific reference article. \"before\" and \"after\" require reference_article_id."),
2900
2955
  reference_article_id: z.number().int().optional().describe("The sibling article to position next to when target is \"before\" or \"after\" (numeric id from list_articles). Must belong to the same section and differ from article_id; leave it unset for \"top\" or \"bottom\"."),
2901
2956
  normalize: z.boolean().default(false).describe("When true, also renumber every article in the section to contiguous positions (0, 1, 2, …) so the stored positions stay tidy. Defaults to false, which writes the fewest positions possible and lets gaps remain. Either way the confirmation threshold still applies."),
2902
- confirm: z.boolean().default(false).describe("Safety guard for large reorders. When the move would rewrite more article positions than the configured threshold (ZENDESK_REORDER_CONFIRM_THRESHOLD, default 20), the tool refuses and reports the count until you pass true here. Has no effect on small reorders.")
2957
+ confirm: z.boolean().default(false).describe("Safety guard for large reorders. When the move would rewrite more article positions than the configured threshold (REORDER_CONFIRM_THRESHOLD, default 20), the tool refuses and reports the count until you pass true here. Has no effect on small reorders.")
2903
2958
  }),
2904
2959
  annotations: {
2905
2960
  readOnlyHint: false,
@@ -3382,7 +3437,7 @@ const END_USER_FORM_PARAMS = {
3382
3437
  * form invisible or a required field unenforced, and the caller then gets a
3383
3438
  * confidently wrong "no such form" or an opaque Zendesk 422.
3384
3439
  */
3385
- const fetchAllPages = async ({ subdomain, token, tool, path, extract, params = {}, maxPages = TICKET_FIELD_SCAN_MAX_PAGES, capEnvVar = "ZENDESK_TICKET_FIELD_SCAN_MAX_PAGES", onPage, forbiddenHint }) => {
3440
+ const fetchAllPages = async ({ subdomain, token, tool, path, extract, params = {}, maxPages = TICKET_FIELD_SCAN_MAX_PAGES, capEnvVar = "TICKET_FIELD_SCAN_MAX_PAGES", onPage, forbiddenHint }) => {
3386
3441
  const items = [];
3387
3442
  let page = 1;
3388
3443
  while (true) {
@@ -3439,7 +3494,7 @@ const fetchAllRequestComments = async (subdomain, token, requestId) => {
3439
3494
  extract: (response) => response.comments,
3440
3495
  params: { include: "users" },
3441
3496
  maxPages: MAX_COMMENT_PAGES,
3442
- capEnvVar: "ZENDESK_MAX_COMMENT_PAGES",
3497
+ capEnvVar: "COMMENT_MAX_PAGES",
3443
3498
  forbiddenHint: OTHER_USERS_REQUEST_HINT,
3444
3499
  onPage: (response) => {
3445
3500
  for (const user of response.users ?? []) authors.set(user.id, user);
@@ -4097,6 +4152,37 @@ const collectAuditIds = (audits) => {
4097
4152
  groupIds: [...groupIds]
4098
4153
  };
4099
4154
  };
4155
+ const SUBSCRIBER_PARAMS = [{
4156
+ param: "followers",
4157
+ field: "follower_ids"
4158
+ }, {
4159
+ param: "email_ccs",
4160
+ field: "email_cc_ids"
4161
+ }];
4162
+ const toSubscriberActions = (edit) => {
4163
+ if (!edit) return void 0;
4164
+ const removed = new Set(edit.remove);
4165
+ const actions = [...[...new Set(edit.add)].filter((id) => !removed.has(id)).map((id) => ({
4166
+ user_id: id,
4167
+ action: "put"
4168
+ })), ...[...removed].map((id) => ({
4169
+ user_id: id,
4170
+ action: "delete"
4171
+ }))];
4172
+ return actions.length > 0 ? actions : void 0;
4173
+ };
4174
+ const unappliedActions = (actions, ids) => {
4175
+ const present = Array.isArray(ids) ? new Set(ids) : void 0;
4176
+ return actions.filter(({ user_id, action }) => {
4177
+ if (action === "put") return !present?.has(user_id);
4178
+ return present === void 0 || present.has(user_id);
4179
+ });
4180
+ };
4181
+ const formatSubscriberOutcome = (sent, after) => {
4182
+ const unconfirmed = SUBSCRIBER_PARAMS.flatMap(({ param, field }) => unappliedActions(sent[param] ?? [], after[field]).map(({ user_id, action }) => `${param} ${action === "put" ? "add" : "remove"} ${user_id}`));
4183
+ if (unconfirmed.length === 0) return "";
4184
+ return `\n\nUnconfirmed: ${unconfirmed.join(", ")}. Zendesk applies followers and email CCs silently: an id it does not know is ignored, and both are ignored entirely when the account's "CCs and followers" setting is off. Check that each id exists with get_user, and the account setting with a Zendesk admin.`;
4185
+ };
4100
4186
  const resolveEntityNames = async (subdomain, token, path, key, ids) => {
4101
4187
  const map = /* @__PURE__ */ new Map();
4102
4188
  for (const batch of chunk(ids, 100)) try {
@@ -4113,13 +4199,14 @@ const resolveAuditNames = async (subdomain, token, userIds, groupIds) => {
4113
4199
  groups
4114
4200
  };
4115
4201
  };
4116
- const resolveCommentAuthors = async (subdomain, token, comments, sideloaded = []) => {
4117
- const authors = new Map(sideloaded.map((user) => [user.id, user.name]));
4118
- const missing = [...new Set(comments.map((comment) => comment.author_id))].filter((id) => id > 0 && !authors.has(id));
4119
- if (missing.length === 0) return authors;
4120
- for (const [id, name] of await resolveUserNames(subdomain, token, missing)) authors.set(id, name);
4121
- return authors;
4202
+ const resolveUserDisplayNames = async (subdomain, token, ids, sideloaded = []) => {
4203
+ const names = new Map(sideloaded.map((user) => [user.id, user.name]));
4204
+ const missing = [...new Set(ids)].filter((id) => id > 0 && !names.has(id));
4205
+ if (missing.length === 0) return names;
4206
+ for (const [id, name] of await resolveUserNames(subdomain, token, missing)) names.set(id, name);
4207
+ return names;
4122
4208
  };
4209
+ const collectSubscriberIds = (ticket) => [...ticket.follower_ids ?? [], ...ticket.email_cc_ids ?? []];
4123
4210
  const DIFF_SKIP_KEYS = /* @__PURE__ */ new Set([
4124
4211
  "comment",
4125
4212
  "fields",
@@ -4189,13 +4276,17 @@ const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
4189
4276
  };
4190
4277
  const createTicketTools = (ctx) => {
4191
4278
  const { subdomain, getToken } = ctx;
4279
+ const subscriberIdList = (description, max) => {
4280
+ const ids = z.array(z.number().int().positive());
4281
+ return (max === void 0 ? ids : ids.max(max)).optional().describe(description);
4282
+ };
4192
4283
  return [
4193
4284
  {
4194
4285
  name: "get_ticket",
4195
4286
  namespace: "tickets",
4196
4287
  readOnly: true,
4197
4288
  title: "Get Zendesk Ticket",
4198
- description: "Retrieve a Zendesk ticket by ID, including its live SLA state (per-metric stage and breach countdown) when an SLA policy applies, plus its comments if requested. Returns ticket details (subject, status, priority, assignee, tags, description) and optionally all comments/internal notes. The per-ticket Show endpoint exposes no SLA, so the SLA block is resolved via a scoped search and may be absent for a very high-volume requester or a just-updated ticket; SLA targets and policy conditions live in list_sla_policies. This returns the ticket as it stands now; for the history of changes behind that state (who changed what, and when), use get_ticket_history. The comment thread is appended in one block — the first page of comments Zendesk returns, cut past the response character limit — so on a long ticket read it with list_ticket_comments, which pages the comments and returns the newest first.",
4289
+ description: "Retrieve a Zendesk ticket by ID, including its live SLA state (per-metric stage and breach countdown) when an SLA policy applies, plus its comments if requested. Returns ticket details (subject, status, priority, assignee, tags, description) and optionally all comments/internal notes. It also lists the followers and email CCs on the ticket, resolved to names, which is how you tell who a ticket update actually reaches: followers are notified of updates including internal notes, email CCs take part in the public correspondence, and being the requester makes someone neither. Both lists come back empty on an account where the \"CCs and followers\" setting is not enabled in Zendesk. The per-ticket Show endpoint exposes no SLA, so the SLA block is resolved via a scoped search and may be absent for a very high-volume requester or a just-updated ticket; SLA targets and policy conditions live in list_sla_policies. This returns the ticket as it stands now; for the history of changes behind that state (who changed what, and when), use get_ticket_history. The comment thread is appended in one block — the first page of comments Zendesk returns, cut past the response character limit — so on a long ticket read it with list_ticket_comments, which pages the comments and returns the newest first.",
4199
4290
  inputSchema: z.object({
4200
4291
  ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket to fetch. Obtain it from search_tickets or list_tickets."),
4201
4292
  include_comments: z.boolean().default(false).describe("When true, appends the full public comment and internal note thread to the response. Defaults to false to keep the payload small; enable it when you need the conversation, not just the ticket fields. On a long thread prefer list_ticket_comments — this flag appends one unpaginated block, so comments past Zendesk's first page are absent and the rest is cut at the response character limit.")
@@ -4210,15 +4301,14 @@ const createTicketTools = (ctx) => {
4210
4301
  const { ticket_id, include_comments } = params;
4211
4302
  const token = await getToken();
4212
4303
  const { ticket } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}`);
4213
- let text = formatTicket(ticket) + formatSlaBlock(await fetchTicketSla(subdomain, token, ticket));
4214
- if (include_comments) {
4215
- const { comments, users } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments`, {
4216
- include: "users",
4217
- include_inline_images: "true"
4218
- });
4219
- const authors = await resolveCommentAuthors(subdomain, token, comments ?? [], users);
4220
- text += `\n\n---\n# Comments\n\n${(comments ?? []).map((comment) => formatComment(comment, authors)).join("\n\n")}`;
4221
- }
4304
+ const [sla, thread] = await Promise.all([fetchTicketSla(subdomain, token, ticket), include_comments ? zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments`, {
4305
+ include: "users",
4306
+ include_inline_images: "true"
4307
+ }) : void 0]);
4308
+ const comments = thread?.comments ?? [];
4309
+ const names = await resolveUserDisplayNames(subdomain, token, [...collectSubscriberIds(ticket), ...comments.map((comment) => comment.author_id)], thread?.users);
4310
+ const commentsBlock = thread ? `\n\n---\n# Comments\n\n${comments.map((comment) => formatComment(comment, names)).join("\n\n")}` : "";
4311
+ const text = formatTicket(ticket) + formatSlaBlock(sla) + formatSubscribersBlock(ticket, names) + commentsBlock;
4222
4312
  const advice = include_comments ? `get_ticket appends the thread as one unpaginated block; read it page by page with list_ticket_comments (ticket_id: ${ticket_id}, sort_order: "desc") to get the newest comments first.` : "get_ticket takes no pagination or filter parameters, so this response cannot be narrowed from the call.";
4223
4313
  return { content: [{
4224
4314
  type: "text",
@@ -4305,7 +4395,7 @@ const createTicketTools = (ctx) => {
4305
4395
  type: "text",
4306
4396
  text: `${meta.has_more ? `No comments on this page of ticket #${ticket_id}. More available (cursor: ${meta.after_cursor}).` : `No comments to show for ticket #${ticket_id}.`}${offsetNote}`
4307
4397
  }] };
4308
- const authors = await resolveCommentAuthors(subdomain, token, comments, response.users);
4398
+ const authors = await resolveUserDisplayNames(subdomain, token, comments.map((comment) => comment.author_id), response.users);
4309
4399
  const body = comments.map((comment) => formatComment(comment, authors)).join("\n\n");
4310
4400
  const text = `${[
4311
4401
  `# Comments on ticket #${ticket_id} (${commentPageOrder(comments, sort_order)})`,
@@ -4435,7 +4525,7 @@ const createTicketTools = (ctx) => {
4435
4525
  namespace: "tickets",
4436
4526
  readOnly: false,
4437
4527
  title: "Update Zendesk Ticket",
4438
- 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.",
4528
+ description: "Update an existing ticket (status, priority, type, assignee, group, subject, tags, custom fields, followers, email CCs). Only the fields you pass are changed, and the updated ticket is returned. Followers and email CCs are incremental instead of replacing: pass { add, remove } lists of user ids, and the response reports who is subscribed afterwards. Zendesk applies those two silently, so a requested change it did not apply comes back listed as unconfirmed rather than raised as an error. 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.",
4439
4529
  inputSchema: z.object({
4440
4530
  ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket to update. Obtain it from search_tickets or list_tickets."),
4441
4531
  status: z.enum([
@@ -4465,7 +4555,15 @@ const createTicketTools = (ctx) => {
4465
4555
  custom_fields: z.array(z.object({
4466
4556
  id: z.number().int(),
4467
4557
  value: z.unknown()
4468
- })).optional().describe("Custom field values as { id, value } pairs (field ids come from your Zendesk admin settings). Call list_ticket_fields first to discover the numeric field ids and, for dropdown/multiselect fields, the exact option values Zendesk accepts.")
4558
+ })).optional().describe("Custom field values as { id, value } pairs (field ids come from your Zendesk admin settings). Call list_ticket_fields first to discover the numeric field ids and, for dropdown/multiselect fields, the exact option values Zendesk accepts."),
4559
+ followers: z.object({
4560
+ add: subscriberIdList("User ids to start following the ticket. Omit to only remove."),
4561
+ remove: subscriberIdList("User ids to stop following the ticket. Omit to only add.")
4562
+ }).strict().optional().describe("Agents to add to or remove from the ticket's follower list, as numeric user ids: { add: [123], remove: [456] }. Followers are notified of ticket updates, internal notes included, so this controls who hears about the ticket. Numeric ids only, no email addresses: resolve the person with search_users first and confirm the match before writing, because a name query can return several users and removing the wrong id succeeds silently. Adding someone already following, or removing someone who is not, is a no-op. An id listed in both add and remove is removed. Omit to leave followers untouched."),
4563
+ email_ccs: z.object({
4564
+ add: subscriberIdList("User ids to CC on the ticket, at most 48. Omit to only remove.", 48),
4565
+ remove: subscriberIdList("User ids to drop from the CC list. Omit to only add.")
4566
+ }).strict().optional().describe("End users or agents to add to or remove from the ticket's email CC list, as numeric user ids: { add: [123], remove: [456] }. CCs receive the ticket's public correspondence, subject to your account's triggers. Numeric ids only, no email addresses: resolve the person with search_users first and confirm the match, because a name query can return several users. Zendesk caps a ticket at 48 email CCs and this tool cannot know how many are already set, so going over shows up as an unconfirmed entry in the response rather than a validation error. An id listed in both add and remove is removed. Omit to leave CCs untouched.")
4469
4567
  }),
4470
4568
  annotations: {
4471
4569
  readOnlyHint: false,
@@ -4474,12 +4572,25 @@ const createTicketTools = (ctx) => {
4474
4572
  openWorldHint: true
4475
4573
  },
4476
4574
  handler: async (params) => {
4477
- const { ticket_id, ...updates } = params;
4575
+ const { ticket_id, followers, email_ccs, ...updates } = params;
4478
4576
  const token = await getToken();
4479
- const { ticket } = await zendeskPut(subdomain, token, `/tickets/${ticket_id}`, { ticket: updates });
4577
+ const sent = {};
4578
+ const followerActions = toSubscriberActions(followers);
4579
+ const ccActions = toSubscriberActions(email_ccs);
4580
+ if (followerActions) sent.followers = followerActions;
4581
+ if (ccActions) sent.email_ccs = ccActions;
4582
+ const { ticket } = await zendeskPut(subdomain, token, `/tickets/${ticket_id}`, { ticket: {
4583
+ ...updates,
4584
+ ...sent
4585
+ } });
4586
+ let text = `Ticket #${ticket.id} updated.\n\n${formatTicket(ticket)}`;
4587
+ if (followerActions || ccActions) {
4588
+ const names = await resolveUserDisplayNames(subdomain, token, collectSubscriberIds(ticket));
4589
+ text += formatSubscribersBlock(ticket, names) + formatSubscriberOutcome(sent, ticket);
4590
+ }
4480
4591
  return { content: [{
4481
4592
  type: "text",
4482
- text: `Ticket #${ticket.id} updated.\n\n${formatTicket(ticket)}`
4593
+ text
4483
4594
  }] };
4484
4595
  }
4485
4596
  },
@@ -4994,7 +5105,7 @@ const createStrictParamsParser = (schema) => {
4994
5105
  return (params) => {
4995
5106
  const result = strict.safeParse(params);
4996
5107
  if (result.success) return result.data;
4997
- const unknownKeys = result.error.issues.filter((issue) => issue.code === "unrecognized_keys").flatMap((issue) => issue.keys ?? []);
5108
+ const unknownKeys = result.error.issues.flatMap((issue) => issue.code === "unrecognized_keys" ? issue.keys : []);
4998
5109
  if (unknownKeys.length > 0) throw new Error(`Unknown parameter(s): ${unknownKeys.join(", ")}. Valid parameters: ${validKeys || "(none)"}.`);
4999
5110
  throw result.error;
5000
5111
  };
@@ -5059,10 +5170,10 @@ const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnaut
5059
5170
  return server.registerTool(toolName, {
5060
5171
  title,
5061
5172
  description: `${prefix}${title}. Specify the operation and its parameters.\n\nAvailable operations:\n${operationList}`,
5062
- inputSchema: {
5173
+ inputSchema: z.object({
5063
5174
  operation: z.string().describe(`One of: ${operationNames.join(", ")}`),
5064
5175
  params: z.record(z.string(), z.unknown()).default({}).describe("Operation parameters")
5065
- },
5176
+ }),
5066
5177
  annotations
5067
5178
  }, async (args) => dispatch(args));
5068
5179
  };
@@ -5681,7 +5792,7 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
5681
5792
  }
5682
5793
  const auth = { bearer };
5683
5794
  const server = createMcpServer(config, () => auth.bearer, logger);
5684
- const transport = new StreamableHTTPServerTransport({
5795
+ const transport = new NodeStreamableHTTPServerTransport({
5685
5796
  sessionIdGenerator: () => randomUUID(),
5686
5797
  onsessioninitialized: (newId) => {
5687
5798
  sessions.set(newId, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fruggr/zendesk-mcp-server",
3
- "version": "2.22.3",
3
+ "version": "2.24.0",
4
4
  "mcpName": "io.github.fruggr/zendesk-mcp-server",
5
5
  "description": "Deep Zendesk MCP server for your AI assistant: search, draft, update and translate Help Center articles and manage Support tickets end to end — comments, triage and image attachments.",
6
6
  "type": "module",
@@ -70,13 +70,14 @@
70
70
  "engines": {
71
71
  "node": ">=20"
72
72
  },
73
- "packageManager": "pnpm@12.4.2+sha512.08adc6613180275c7c9edada39dcf08c9c61ad4e7eaf330a4f3461f102b0f907423454d117f98e72d47fef0616070644d7bffc973a6a57f5090a6d7c368b07c9",
73
+ "packageManager": "pnpm@12.5.1+sha512.e3f305bc784a2bc89f5ad3b6138889470fae8d2af5f36b61216ec91c2c3d64089775f9de38aac331044ea40f245cb0d5666392dfdf65824e1907ef6a2c62de5f",
74
74
  "dependencies": {
75
- "@modelcontextprotocol/sdk": "1.30.0",
75
+ "@modelcontextprotocol/node": "2.0.0",
76
+ "@modelcontextprotocol/server": "2.0.0",
76
77
  "cheerio": "1.2.0",
77
78
  "hast-util-to-html": "9.0.5",
78
79
  "hast-util-to-mdast": "10.1.2",
79
- "open": "11.0.2",
80
+ "open": "11.0.4",
80
81
  "rehype-parse": "9.0.1",
81
82
  "rehype-raw": "7.0.0",
82
83
  "rehype-remark": "10.0.1",
@@ -86,10 +87,11 @@
86
87
  "remark-rehype": "11.1.2",
87
88
  "remark-stringify": "11.0.0",
88
89
  "unified": "11.0.5",
89
- "zod": "4.6.2"
90
+ "zod": "4.6.5"
90
91
  },
91
92
  "devDependencies": {
92
- "@biomejs/biome": "2.5.12",
93
+ "@biomejs/biome": "2.5.13",
94
+ "@modelcontextprotocol/client": "2.0.0",
93
95
  "@semantic-release/changelog": "^7.0.0",
94
96
  "@semantic-release/exec": "^7.1.0",
95
97
  "@semantic-release/git": "^11.0.0",