@fruggr/zendesk-mcp-server 2.22.2 → 2.23.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 +2 -0
  2. package/dist/index.js +128 -50
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -152,6 +152,8 @@ 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
154
  and overrides: [`ZENDESK_TOKEN_FILE`](docs/configuration.md#zendesk_token_file)).
155
+ Several instances can run side by side — a read-write one and a `--read-only`
156
+ one, say — without trading credentials.
155
157
 
156
158
  ### MCP client wiring
157
159
 
package/dist/index.js CHANGED
@@ -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 {
@@ -176,9 +176,11 @@ const requestedScope = (readOnly) => readOnly ? READ_SCOPE : READ_WRITE_SCOPE;
176
176
  const supportedScopes = (readOnly) => scopeTokens(requestedScope(readOnly));
177
177
  /**
178
178
  * Whether a grant still covers what this process needs: a flat subset test.
179
- * Coverage, not equality, so a broader token stays usable and two servers
180
- * sharing a token file converge. A non-string `granted` is a pre-#283 record,
181
- * i.e. `read write`. No scope hierarchy: granular scopes (#284) replace this.
179
+ * Coverage, not equality, so a token Zendesk granted wider than requested stays
180
+ * 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
182
+ * default layout no longer names those files. No scope hierarchy: granular
183
+ * scopes (#284) replace this.
182
184
  */
183
185
  const grantCovers = (granted, requested) => {
184
186
  if (typeof granted !== "string") return true;
@@ -199,11 +201,13 @@ const detectWsl = () => {
199
201
  };
200
202
  /**
201
203
  * Build an actionable error for a callback port that's already taken. The raw
202
- * Node `EADDRINUSE` is opaque to both the user and the LLM; this spells out the
203
- * fix (set a free port + register the matching redirect URL in Zendesk). The
204
- * `(EADDRINUSE)` marker and `code` are kept for diagnostics/tests.
204
+ * Node `EADDRINUSE` is opaque to both the user and the LLM. The port is also
205
+ * the mutex between instances, so retrying once the other sign-in ends comes
206
+ * first and the free-port escape hatch second
207
+ * (`docs/decisions/token-file-keying.md`). The `(EADDRINUSE)` marker and `code`
208
+ * are kept for diagnostics/tests.
205
209
  */
206
- const callbackPortInUseError = (port, cause) => Object.assign(/* @__PURE__ */ new Error(`Cannot start the Zendesk OAuth sign-in: local callback port ${port} is already in use by another process. 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)`), {
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)`), {
207
211
  code: "EADDRINUSE",
208
212
  cause
209
213
  });
@@ -483,18 +487,31 @@ const configDir = () => {
483
487
  const base = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config");
484
488
  return join(base, ...segments);
485
489
  };
486
- const safeName = (subdomain) => subdomain.replace(/[^a-z0-9-]/gi, "_");
490
+ const safeName = (part) => part.replace(/[^a-z0-9-]/gi, "_");
491
+ const READABLE_BUDGET = 120;
492
+ const keyDigest = (key) => createHash("sha256").update(JSON.stringify([
493
+ key.subdomain,
494
+ key.oauthClientId,
495
+ key.scope
496
+ ])).digest("hex").slice(0, 8);
487
497
  /**
488
- * Path to the token file for a subdomain. Each subdomain gets its **own** file
489
- * (`<subdomain>.json`, a single record) so concurrent processes for different
490
- * subdomains never read-modify-write a shared file — no merge, no clobber.
491
- * `ZENDESK_TOKEN_FILE` overrides with an explicit path (a single file; use the
492
- * default layout for multi-subdomain installs).
498
+ * Path to the token file for one credential. Keyed on the whole `TokenKey`, so
499
+ * a read-only server, a read-write one and one on another OAuth client each get
500
+ * their own record instead of clobbering a shared file. Why that triple, and
501
+ * why no migration from the old subdomain-only layout:
502
+ * `docs/decisions/token-file-keying.md`.
503
+ * `ZENDESK_TOKEN_FILE` overrides with an explicit path — the way to separate
504
+ * two Zendesk accounts that share a subdomain, client and scope.
493
505
  */
494
- const resolveTokenPath = (subdomain) => {
506
+ const resolveTokenPath = (key) => {
495
507
  const override = process.env["ZENDESK_TOKEN_FILE"];
496
508
  if (override) return override;
497
- return join(configDir(), `${safeName(subdomain)}.json`);
509
+ const readable = [
510
+ key.subdomain,
511
+ key.oauthClientId,
512
+ key.scope
513
+ ].map(safeName).join("--").slice(0, READABLE_BUDGET);
514
+ return join(configDir(), `${readable}--${keyDigest(key)}.json`);
498
515
  };
499
516
  const writeFileAtomic = (path, record) => {
500
517
  const dir = dirname(path);
@@ -539,8 +556,12 @@ const createAuthRequiredError = (authorizeUrl) => Object.assign(/* @__PURE__ */
539
556
  });
540
557
  const expiryFrom = (expiresIn) => typeof expiresIn === "number" ? Date.now() + expiresIn * 1e3 : void 0;
541
558
  const createTokenStore = (config, logger = silentLogger) => {
542
- const tokenPath = resolveTokenPath(config.subdomain);
543
559
  const requested = requestedScope(config.readOnly);
560
+ const tokenPath = resolveTokenPath({
561
+ subdomain: config.subdomain,
562
+ oauthClientId: config.oauthClientId,
563
+ scope: requested
564
+ });
544
565
  let token = loadToken(tokenPath);
545
566
  if (token) {
546
567
  if (grantCovers(token.scope, requested)) logger.debug("oauth_token_loaded_from_disk");
@@ -770,11 +791,7 @@ const ConfigSchema = z.object({
770
791
  abort: true
771
792
  }).refine((scheme) => {
772
793
  const uri = `${scheme}://topology`;
773
- try {
774
- return new URL(uri).toString() === uri;
775
- } catch {
776
- return false;
777
- }
794
+ return new URL(uri).toString() === uri;
778
795
  }, { 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"),
779
796
  /**
780
797
  * Dev-only (stdio): expose the `reload_tools` tool, which re-imports the tool
@@ -1310,8 +1327,7 @@ const htmlToMdProcessor = unified().use(rehypeParse, { fragment: true }).use(reh
1310
1327
  pre: keepAsHtml
1311
1328
  } }).use(remarkGfm).use(remarkStringify, {
1312
1329
  bullet: "-",
1313
- emphasis: "_",
1314
- fences: true
1330
+ emphasis: "_"
1315
1331
  });
1316
1332
  const mdToHtmlProcessor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw).use(rehypeStringify);
1317
1333
  const htmlToMarkdown = (html) => {
@@ -1429,6 +1445,12 @@ const withName = (id, names) => {
1429
1445
  const name = names.get(n);
1430
1446
  return name ? `${name} (${id})` : String(id);
1431
1447
  };
1448
+ const formatSubscribersBlock = (ticket, names) => {
1449
+ const line = (label, ids) => `- **${label}**: ${ids.length > 0 ? ids.map((id) => withName(id, names)).join(", ") : "none"}`;
1450
+ 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);
1451
+ if (rows.length === 0) return "";
1452
+ return `\n\n${["### Subscribers", ...rows].join("\n")}`;
1453
+ };
1432
1454
  const formatComment = (comment, authors) => {
1433
1455
  const lines = [`### ${comment.public ? "Public comment" : "Internal note"} (id ${comment.id}) by ${withName(comment.author_id, authors ?? /* @__PURE__ */ new Map())}`, `*${comment.created_at}*`];
1434
1456
  if (comment.attachments?.length) {
@@ -2021,9 +2043,9 @@ const NAMESPACE_LABELS = {
2021
2043
  //#endregion
2022
2044
  //#region src/utils/article-order.ts
2023
2045
  const hasPositionInversion = (order) => {
2024
- for (let i = 0; i < order.length - 1; i += 1) {
2025
- const here = order[i];
2026
- const next = order[i + 1];
2046
+ for (let i = 1; i < order.length; i += 1) {
2047
+ const here = order[i - 1];
2048
+ const next = order[i];
2027
2049
  if (here && next && here.position > next.position) return true;
2028
2050
  }
2029
2051
  return false;
@@ -4076,6 +4098,37 @@ const collectAuditIds = (audits) => {
4076
4098
  groupIds: [...groupIds]
4077
4099
  };
4078
4100
  };
4101
+ const SUBSCRIBER_PARAMS = [{
4102
+ param: "followers",
4103
+ field: "follower_ids"
4104
+ }, {
4105
+ param: "email_ccs",
4106
+ field: "email_cc_ids"
4107
+ }];
4108
+ const toSubscriberActions = (edit) => {
4109
+ if (!edit) return void 0;
4110
+ const removed = new Set(edit.remove);
4111
+ const actions = [...[...new Set(edit.add)].filter((id) => !removed.has(id)).map((id) => ({
4112
+ user_id: id,
4113
+ action: "put"
4114
+ })), ...[...removed].map((id) => ({
4115
+ user_id: id,
4116
+ action: "delete"
4117
+ }))];
4118
+ return actions.length > 0 ? actions : void 0;
4119
+ };
4120
+ const unappliedActions = (actions, ids) => {
4121
+ const present = Array.isArray(ids) ? new Set(ids) : void 0;
4122
+ return actions.filter(({ user_id, action }) => {
4123
+ if (action === "put") return !present?.has(user_id);
4124
+ return present === void 0 || present.has(user_id);
4125
+ });
4126
+ };
4127
+ const formatSubscriberOutcome = (sent, after) => {
4128
+ const unconfirmed = SUBSCRIBER_PARAMS.flatMap(({ param, field }) => unappliedActions(sent[param] ?? [], after[field]).map(({ user_id, action }) => `${param} ${action === "put" ? "add" : "remove"} ${user_id}`));
4129
+ if (unconfirmed.length === 0) return "";
4130
+ 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.`;
4131
+ };
4079
4132
  const resolveEntityNames = async (subdomain, token, path, key, ids) => {
4080
4133
  const map = /* @__PURE__ */ new Map();
4081
4134
  for (const batch of chunk(ids, 100)) try {
@@ -4092,13 +4145,14 @@ const resolveAuditNames = async (subdomain, token, userIds, groupIds) => {
4092
4145
  groups
4093
4146
  };
4094
4147
  };
4095
- const resolveCommentAuthors = async (subdomain, token, comments, sideloaded = []) => {
4096
- const authors = new Map(sideloaded.map((user) => [user.id, user.name]));
4097
- const missing = [...new Set(comments.map((comment) => comment.author_id))].filter((id) => id > 0 && !authors.has(id));
4098
- if (missing.length === 0) return authors;
4099
- for (const [id, name] of await resolveUserNames(subdomain, token, missing)) authors.set(id, name);
4100
- return authors;
4148
+ const resolveUserDisplayNames = async (subdomain, token, ids, sideloaded = []) => {
4149
+ const names = new Map(sideloaded.map((user) => [user.id, user.name]));
4150
+ const missing = [...new Set(ids)].filter((id) => id > 0 && !names.has(id));
4151
+ if (missing.length === 0) return names;
4152
+ for (const [id, name] of await resolveUserNames(subdomain, token, missing)) names.set(id, name);
4153
+ return names;
4101
4154
  };
4155
+ const collectSubscriberIds = (ticket) => [...ticket.follower_ids ?? [], ...ticket.email_cc_ids ?? []];
4102
4156
  const DIFF_SKIP_KEYS = /* @__PURE__ */ new Set([
4103
4157
  "comment",
4104
4158
  "fields",
@@ -4168,13 +4222,17 @@ const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
4168
4222
  };
4169
4223
  const createTicketTools = (ctx) => {
4170
4224
  const { subdomain, getToken } = ctx;
4225
+ const subscriberIdList = (description, max) => {
4226
+ const ids = z.array(z.number().int().positive());
4227
+ return (max === void 0 ? ids : ids.max(max)).optional().describe(description);
4228
+ };
4171
4229
  return [
4172
4230
  {
4173
4231
  name: "get_ticket",
4174
4232
  namespace: "tickets",
4175
4233
  readOnly: true,
4176
4234
  title: "Get Zendesk Ticket",
4177
- 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.",
4235
+ 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.",
4178
4236
  inputSchema: z.object({
4179
4237
  ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket to fetch. Obtain it from search_tickets or list_tickets."),
4180
4238
  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.")
@@ -4189,15 +4247,14 @@ const createTicketTools = (ctx) => {
4189
4247
  const { ticket_id, include_comments } = params;
4190
4248
  const token = await getToken();
4191
4249
  const { ticket } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}`);
4192
- let text = formatTicket(ticket) + formatSlaBlock(await fetchTicketSla(subdomain, token, ticket));
4193
- if (include_comments) {
4194
- const { comments, users } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments`, {
4195
- include: "users",
4196
- include_inline_images: "true"
4197
- });
4198
- const authors = await resolveCommentAuthors(subdomain, token, comments ?? [], users);
4199
- text += `\n\n---\n# Comments\n\n${(comments ?? []).map((comment) => formatComment(comment, authors)).join("\n\n")}`;
4200
- }
4250
+ const [sla, thread] = await Promise.all([fetchTicketSla(subdomain, token, ticket), include_comments ? zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments`, {
4251
+ include: "users",
4252
+ include_inline_images: "true"
4253
+ }) : void 0]);
4254
+ const comments = thread?.comments ?? [];
4255
+ const names = await resolveUserDisplayNames(subdomain, token, [...collectSubscriberIds(ticket), ...comments.map((comment) => comment.author_id)], thread?.users);
4256
+ const commentsBlock = thread ? `\n\n---\n# Comments\n\n${comments.map((comment) => formatComment(comment, names)).join("\n\n")}` : "";
4257
+ const text = formatTicket(ticket) + formatSlaBlock(sla) + formatSubscribersBlock(ticket, names) + commentsBlock;
4201
4258
  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.";
4202
4259
  return { content: [{
4203
4260
  type: "text",
@@ -4284,7 +4341,7 @@ const createTicketTools = (ctx) => {
4284
4341
  type: "text",
4285
4342
  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}`
4286
4343
  }] };
4287
- const authors = await resolveCommentAuthors(subdomain, token, comments, response.users);
4344
+ const authors = await resolveUserDisplayNames(subdomain, token, comments.map((comment) => comment.author_id), response.users);
4288
4345
  const body = comments.map((comment) => formatComment(comment, authors)).join("\n\n");
4289
4346
  const text = `${[
4290
4347
  `# Comments on ticket #${ticket_id} (${commentPageOrder(comments, sort_order)})`,
@@ -4414,7 +4471,7 @@ const createTicketTools = (ctx) => {
4414
4471
  namespace: "tickets",
4415
4472
  readOnly: false,
4416
4473
  title: "Update Zendesk Ticket",
4417
- 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.",
4474
+ 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.",
4418
4475
  inputSchema: z.object({
4419
4476
  ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket to update. Obtain it from search_tickets or list_tickets."),
4420
4477
  status: z.enum([
@@ -4444,7 +4501,15 @@ const createTicketTools = (ctx) => {
4444
4501
  custom_fields: z.array(z.object({
4445
4502
  id: z.number().int(),
4446
4503
  value: z.unknown()
4447
- })).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.")
4504
+ })).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."),
4505
+ followers: z.object({
4506
+ add: subscriberIdList("User ids to start following the ticket. Omit to only remove."),
4507
+ remove: subscriberIdList("User ids to stop following the ticket. Omit to only add.")
4508
+ }).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."),
4509
+ email_ccs: z.object({
4510
+ add: subscriberIdList("User ids to CC on the ticket, at most 48. Omit to only remove.", 48),
4511
+ remove: subscriberIdList("User ids to drop from the CC list. Omit to only add.")
4512
+ }).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.")
4448
4513
  }),
4449
4514
  annotations: {
4450
4515
  readOnlyHint: false,
@@ -4453,12 +4518,25 @@ const createTicketTools = (ctx) => {
4453
4518
  openWorldHint: true
4454
4519
  },
4455
4520
  handler: async (params) => {
4456
- const { ticket_id, ...updates } = params;
4521
+ const { ticket_id, followers, email_ccs, ...updates } = params;
4457
4522
  const token = await getToken();
4458
- const { ticket } = await zendeskPut(subdomain, token, `/tickets/${ticket_id}`, { ticket: updates });
4523
+ const sent = {};
4524
+ const followerActions = toSubscriberActions(followers);
4525
+ const ccActions = toSubscriberActions(email_ccs);
4526
+ if (followerActions) sent.followers = followerActions;
4527
+ if (ccActions) sent.email_ccs = ccActions;
4528
+ const { ticket } = await zendeskPut(subdomain, token, `/tickets/${ticket_id}`, { ticket: {
4529
+ ...updates,
4530
+ ...sent
4531
+ } });
4532
+ let text = `Ticket #${ticket.id} updated.\n\n${formatTicket(ticket)}`;
4533
+ if (followerActions || ccActions) {
4534
+ const names = await resolveUserDisplayNames(subdomain, token, collectSubscriberIds(ticket));
4535
+ text += formatSubscribersBlock(ticket, names) + formatSubscriberOutcome(sent, ticket);
4536
+ }
4459
4537
  return { content: [{
4460
4538
  type: "text",
4461
- text: `Ticket #${ticket.id} updated.\n\n${formatTicket(ticket)}`
4539
+ text
4462
4540
  }] };
4463
4541
  }
4464
4542
  },
@@ -4973,7 +5051,7 @@ const createStrictParamsParser = (schema) => {
4973
5051
  return (params) => {
4974
5052
  const result = strict.safeParse(params);
4975
5053
  if (result.success) return result.data;
4976
- const unknownKeys = result.error.issues.filter((issue) => issue.code === "unrecognized_keys").flatMap((issue) => issue.keys ?? []);
5054
+ const unknownKeys = result.error.issues.flatMap((issue) => issue.code === "unrecognized_keys" ? issue.keys : []);
4977
5055
  if (unknownKeys.length > 0) throw new Error(`Unknown parameter(s): ${unknownKeys.join(", ")}. Valid parameters: ${validKeys || "(none)"}.`);
4978
5056
  throw result.error;
4979
5057
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fruggr/zendesk-mcp-server",
3
- "version": "2.22.2",
3
+ "version": "2.23.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",
@@ -76,7 +76,7 @@
76
76
  "cheerio": "1.2.0",
77
77
  "hast-util-to-html": "9.0.5",
78
78
  "hast-util-to-mdast": "10.1.2",
79
- "open": "11.0.2",
79
+ "open": "11.0.4",
80
80
  "rehype-parse": "9.0.1",
81
81
  "rehype-raw": "7.0.0",
82
82
  "rehype-remark": "10.0.1",
@@ -86,10 +86,10 @@
86
86
  "remark-rehype": "11.1.2",
87
87
  "remark-stringify": "11.0.0",
88
88
  "unified": "11.0.5",
89
- "zod": "4.6.2"
89
+ "zod": "4.6.5"
90
90
  },
91
91
  "devDependencies": {
92
- "@biomejs/biome": "2.5.12",
92
+ "@biomejs/biome": "2.5.13",
93
93
  "@semantic-release/changelog": "^7.0.0",
94
94
  "@semantic-release/exec": "^7.1.0",
95
95
  "@semantic-release/git": "^11.0.0",