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