@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.
- package/dist/index.js +91 -34
- 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"
|
|
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
|
-
|
|
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 =
|
|
2046
|
-
const here = order[i];
|
|
2047
|
-
const next = order[i
|
|
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
|
|
4117
|
-
const
|
|
4118
|
-
const missing = [...new Set(
|
|
4119
|
-
if (missing.length === 0) return
|
|
4120
|
-
for (const [id, name] of await resolveUserNames(subdomain, token, missing))
|
|
4121
|
-
return
|
|
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
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
89
|
+
"zod": "4.6.5"
|
|
90
90
|
},
|
|
91
91
|
"devDependencies": {
|
|
92
|
-
"@biomejs/biome": "2.5.
|
|
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",
|