@fruggr/zendesk-mcp-server 2.22.3 → 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 (2) hide show
  1. package/dist/index.js +91 -34
  2. package/package.json +4 -4
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 {
@@ -791,11 +791,7 @@ const ConfigSchema = z.object({
791
791
  abort: true
792
792
  }).refine((scheme) => {
793
793
  const uri = `${scheme}://topology`;
794
- try {
795
- return new URL(uri).toString() === uri;
796
- } catch {
797
- return false;
798
- }
794
+ return new URL(uri).toString() === uri;
799
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"),
800
796
  /**
801
797
  * Dev-only (stdio): expose the `reload_tools` tool, which re-imports the tool
@@ -1331,8 +1327,7 @@ const htmlToMdProcessor = unified().use(rehypeParse, { fragment: true }).use(reh
1331
1327
  pre: keepAsHtml
1332
1328
  } }).use(remarkGfm).use(remarkStringify, {
1333
1329
  bullet: "-",
1334
- emphasis: "_",
1335
- fences: true
1330
+ emphasis: "_"
1336
1331
  });
1337
1332
  const mdToHtmlProcessor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw).use(rehypeStringify);
1338
1333
  const htmlToMarkdown = (html) => {
@@ -1450,6 +1445,12 @@ const withName = (id, names) => {
1450
1445
  const name = names.get(n);
1451
1446
  return name ? `${name} (${id})` : String(id);
1452
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
+ };
1453
1454
  const formatComment = (comment, authors) => {
1454
1455
  const lines = [`### ${comment.public ? "Public comment" : "Internal note"} (id ${comment.id}) by ${withName(comment.author_id, authors ?? /* @__PURE__ */ new Map())}`, `*${comment.created_at}*`];
1455
1456
  if (comment.attachments?.length) {
@@ -2042,9 +2043,9 @@ const NAMESPACE_LABELS = {
2042
2043
  //#endregion
2043
2044
  //#region src/utils/article-order.ts
2044
2045
  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];
2046
+ for (let i = 1; i < order.length; i += 1) {
2047
+ const here = order[i - 1];
2048
+ const next = order[i];
2048
2049
  if (here && next && here.position > next.position) return true;
2049
2050
  }
2050
2051
  return false;
@@ -4097,6 +4098,37 @@ const collectAuditIds = (audits) => {
4097
4098
  groupIds: [...groupIds]
4098
4099
  };
4099
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
+ };
4100
4132
  const resolveEntityNames = async (subdomain, token, path, key, ids) => {
4101
4133
  const map = /* @__PURE__ */ new Map();
4102
4134
  for (const batch of chunk(ids, 100)) try {
@@ -4113,13 +4145,14 @@ const resolveAuditNames = async (subdomain, token, userIds, groupIds) => {
4113
4145
  groups
4114
4146
  };
4115
4147
  };
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;
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;
4122
4154
  };
4155
+ const collectSubscriberIds = (ticket) => [...ticket.follower_ids ?? [], ...ticket.email_cc_ids ?? []];
4123
4156
  const DIFF_SKIP_KEYS = /* @__PURE__ */ new Set([
4124
4157
  "comment",
4125
4158
  "fields",
@@ -4189,13 +4222,17 @@ const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
4189
4222
  };
4190
4223
  const createTicketTools = (ctx) => {
4191
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
+ };
4192
4229
  return [
4193
4230
  {
4194
4231
  name: "get_ticket",
4195
4232
  namespace: "tickets",
4196
4233
  readOnly: true,
4197
4234
  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.",
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.",
4199
4236
  inputSchema: z.object({
4200
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."),
4201
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.")
@@ -4210,15 +4247,14 @@ const createTicketTools = (ctx) => {
4210
4247
  const { ticket_id, include_comments } = params;
4211
4248
  const token = await getToken();
4212
4249
  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
- }
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;
4222
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.";
4223
4259
  return { content: [{
4224
4260
  type: "text",
@@ -4305,7 +4341,7 @@ const createTicketTools = (ctx) => {
4305
4341
  type: "text",
4306
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}`
4307
4343
  }] };
4308
- const authors = await resolveCommentAuthors(subdomain, token, comments, response.users);
4344
+ const authors = await resolveUserDisplayNames(subdomain, token, comments.map((comment) => comment.author_id), response.users);
4309
4345
  const body = comments.map((comment) => formatComment(comment, authors)).join("\n\n");
4310
4346
  const text = `${[
4311
4347
  `# Comments on ticket #${ticket_id} (${commentPageOrder(comments, sort_order)})`,
@@ -4435,7 +4471,7 @@ const createTicketTools = (ctx) => {
4435
4471
  namespace: "tickets",
4436
4472
  readOnly: false,
4437
4473
  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.",
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.",
4439
4475
  inputSchema: z.object({
4440
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."),
4441
4477
  status: z.enum([
@@ -4465,7 +4501,15 @@ const createTicketTools = (ctx) => {
4465
4501
  custom_fields: z.array(z.object({
4466
4502
  id: z.number().int(),
4467
4503
  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.")
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.")
4469
4513
  }),
4470
4514
  annotations: {
4471
4515
  readOnlyHint: false,
@@ -4474,12 +4518,25 @@ const createTicketTools = (ctx) => {
4474
4518
  openWorldHint: true
4475
4519
  },
4476
4520
  handler: async (params) => {
4477
- const { ticket_id, ...updates } = params;
4521
+ const { ticket_id, followers, email_ccs, ...updates } = params;
4478
4522
  const token = await getToken();
4479
- 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
+ }
4480
4537
  return { content: [{
4481
4538
  type: "text",
4482
- text: `Ticket #${ticket.id} updated.\n\n${formatTicket(ticket)}`
4539
+ text
4483
4540
  }] };
4484
4541
  }
4485
4542
  },
@@ -4994,7 +5051,7 @@ const createStrictParamsParser = (schema) => {
4994
5051
  return (params) => {
4995
5052
  const result = strict.safeParse(params);
4996
5053
  if (result.success) return result.data;
4997
- 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 : []);
4998
5055
  if (unknownKeys.length > 0) throw new Error(`Unknown parameter(s): ${unknownKeys.join(", ")}. Valid parameters: ${validKeys || "(none)"}.`);
4999
5056
  throw result.error;
5000
5057
  };
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.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",