@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.
- package/README.md +2 -0
- package/dist/index.js +128 -50
- 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"
|
|
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
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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
|
|
203
|
-
*
|
|
204
|
-
*
|
|
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
|
|
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 = (
|
|
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
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
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 = (
|
|
506
|
+
const resolveTokenPath = (key) => {
|
|
495
507
|
const override = process.env["ZENDESK_TOKEN_FILE"];
|
|
496
508
|
if (override) return override;
|
|
497
|
-
|
|
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
|
-
|
|
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 =
|
|
2025
|
-
const here = order[i];
|
|
2026
|
-
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];
|
|
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
|
|
4096
|
-
const
|
|
4097
|
-
const missing = [...new Set(
|
|
4098
|
-
if (missing.length === 0) return
|
|
4099
|
-
for (const [id, name] of await resolveUserNames(subdomain, token, missing))
|
|
4100
|
-
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;
|
|
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
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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",
|