@fruggr/zendesk-mcp-server 2.1.0 → 2.3.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 CHANGED
@@ -7,20 +7,55 @@
7
7
  [![Renovate enabled](https://img.shields.io/badge/renovate-enabled-brightgreen?logo=renovatebot&logoColor=white)](https://renovatebot.com)
8
8
  [![semantic-release](https://img.shields.io/badge/semantic--release-e10079?logo=semantic-release&logoColor=white)](https://github.com/semantic-release/semantic-release)
9
9
 
10
- A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that connects LLMs to the **Zendesk Support & Help Center APIs** with per-user OAuth 2.1 PKCE authentication and fine-grained tool visibility controls. Runs locally over **stdio** or as a private **remote MCP server** over HTTP.
11
-
12
- ## Why this server?
13
-
14
- Most Zendesk integrations use a shared admin API key, giving every user full access to every ticket. This server takes a different approach:
15
-
16
- - **Per-user authentication, OAuth-only** — In both transports, auth is OAuth 2.1 PKCE: each user authenticates with their own Zendesk credentials, so the LLM sees exactly what the user is allowed to see. Static API tokens are deliberately **not** supported (see [below](#what-this-server-does-not-do)).
10
+ **Bring Zendesk Support & the Help Center into your AI assistant.** A
11
+ [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that lets
12
+ your assistant search articles, answer questions, and create, track and update
13
+ tickets in plain language — **without switching apps**.
14
+
15
+ Think of it as the [Zendesk agent for Microsoft 365 Copilot](https://support.zendesk.com/hc/en-us/articles/9958331458458-Using-the-Zendesk-agent-in-Microsoft-365-Copilot),
16
+ but **vendor-neutral** — it drops into any MCP client (Claude Desktop, Claude
17
+ Code, Cursor, VS Code, …) instead of being tied to one assistant — and it always
18
+ acts with **each user's own Zendesk permissions**, never a shared admin key.
19
+
20
+ ## What your assistant can do
21
+
22
+ Ask in natural language; the assistant figures out context and intent, then calls
23
+ the right tools on your behalf:
24
+
25
+ - **Find answers in the Help Center** — "how do I request a software license?" or
26
+ "what's the time-off policy?" surfaces the right article, by meaning, not just
27
+ keywords.
28
+ - **Create, view and update tickets without leaving the conversation** — open a
29
+ ticket, check its status, add a public reply or an internal note, change the
30
+ priority or assignee, or mark it solved.
31
+ - **Summarize a ticket for reporting or a quick decision** — pull the details and
32
+ the full comment thread and get the gist in a sentence.
33
+ - **Search and triage your queue in plain language** — "show me my open tickets
34
+ about billing from this week."
35
+ - **Draft and maintain knowledge-base articles** — write a new article, or revise
36
+ a large one **one section at a time** so the whole HTML body never has to
37
+ round-trip through the model.
38
+
39
+ Because it runs on the **user's own OAuth session**, the assistant only ever sees
40
+ and touches what that person is allowed to — the same scoping you'd get signing
41
+ into Zendesk directly.
42
+
43
+ ## How it's different
44
+
45
+ Most Zendesk integrations use a shared admin API key, giving every user full
46
+ access to every ticket, and bolt on a fixed set of tools. This server is built
47
+ differently:
48
+
49
+ - **Per-user authentication, OAuth-only** — In both transports, auth is OAuth 2.1 PKCE: each user authenticates with their own Zendesk credentials, so the assistant sees exactly what the user is allowed to see. Static API tokens are deliberately **not** supported (see [below](#what-this-server-does-not-do)).
17
50
  - **Two deployment shapes, same auth story** — Run it on your laptop as a stdio MCP server (Claude Desktop / Claude Code / VS Code) or deploy it as a private remote MCP server with one user, one Zendesk session per HTTP request.
18
51
  - **Context-friendly tool modes** — Expose every operation as its own tool, group them into namespace proxies, or collapse to a single unified tool. Tools are segmented into namespaces you can selectively enable, so each context loads only the surface it needs.
19
- - **Section-based article editing** — For large Help Center articles, read and rewrite one section at a time (parsed by h1/h2/h3 headings) instead of shuffling the full HTML body through the LLM. Reduces tokens by 10–100× on targeted edits.
52
+ - **Section-based article editing** — For large Help Center articles, read and rewrite one section at a time (parsed by h1/h2/h3 headings) instead of shuffling the full HTML body through the assistant. Reduces tokens by 10–100× on targeted edits.
20
53
  - **Read-only mode** — Restrict the server to read operations only, ideal for assistants that should never modify data.
21
54
  - **Lean stack** — Built on the official `@modelcontextprotocol/sdk` plus `zod`.
22
55
 
23
- > Built and maintained by [Digital4better](https://digital4better.com) for the [Fruggr](https://www.fruggr.io) project.
56
+ Under the hood it speaks to the **Zendesk Support & Help Center (Guide) APIs**,
57
+ runs locally over **stdio** or as a private **remote MCP server** over HTTP, and
58
+ ships fine-grained tool-visibility controls — the specifics are below.
24
59
 
25
60
  ## When to use this server
26
61
 
@@ -87,11 +122,12 @@ zendesk-mcp-server acme --namespace tickets
87
122
 
88
123
  | Tool | Description | Mode |
89
124
  |------|-------------|------|
90
- | `get_ticket` | Retrieve a ticket by ID with optional comments | read |
125
+ | `get_ticket` | Retrieve a ticket by ID with optional comments and its live SLA state (resolved via a scoped search) | read |
91
126
  | `get_ticket_attachments` | Download ticket attachments (images as base64, others as references) | read |
92
- | `search_tickets` | Search tickets using Zendesk query syntax | read |
127
+ | `search_tickets` | Search tickets using Zendesk query syntax, with per-result SLA state | read |
93
128
  | `list_tickets` | List tickets with cursor-based pagination | read |
94
129
  | `get_linked_incidents` | Get incidents linked to a problem ticket | read |
130
+ | `list_sla_policies` | List SLA policies with filter conditions and per-priority targets (requires an admin token, or a custom role with the SLA-management permission) | read |
95
131
  | `create_ticket` | Create a new ticket with subject, description, priority, tags... | write |
96
132
  | `update_ticket` | Update ticket status, priority, assignee, tags, custom fields | write |
97
133
  | `add_private_note` | Add an internal note (not visible to requester) | write |
@@ -224,8 +260,11 @@ file per subdomain in your OS config dir —
224
260
  elsewhere; override the path with `ZENDESK_TOKEN_FILE`). It is reused across restarts, so you don't
225
261
  re-authenticate every time the MCP client respawns the server. If the Zendesk
226
262
  OAuth client has token expiration enabled, the stored refresh token is used to
227
- renew access silently; only an expired/invalid refresh token triggers a new
228
- browser sign-in.
263
+ renew access silently **proactively** (the token is refreshed before use when
264
+ it's expired, near expiry, or of unknown age, so the first request after an
265
+ overnight gap never hits a visible auth error) and **periodically** in the
266
+ background so a long-lived, idle session never serves a stale token. Only an
267
+ expired/invalid refresh token triggers a new browser sign-in.
229
268
 
230
269
  > **Port conflict?** If port `27439` is already in use the first tool call returns
231
270
  > a clear error telling you to set `ZENDESK_OAUTH_CALLBACK_PORT` (or
@@ -648,3 +687,7 @@ Every PR is reviewed automatically by [CodeRabbit](https://www.coderabbit.ai) in
648
687
  ## License
649
688
 
650
689
  [MIT](LICENSE)
690
+
691
+ ---
692
+
693
+ > Built and maintained by [Digital4better](https://digital4better.com) for the [Fruggr](https://www.fruggr.io) project.
package/dist/index.js CHANGED
@@ -221,7 +221,7 @@ const startBrowserAuth = (config, logger = silentLogger) => {
221
221
  const tokenData = await tokenResponse.json();
222
222
  logger.info("oauth_authenticated");
223
223
  res.writeHead(200, { "Content-Type": "text/html" });
224
- res.end("<html><body><h1>Authentication successful!</h1><p>You can close this tab and return to Claude Code.</p><p>This tab will auto-close in <span id=\"t\">10</span>s.</p><script>let n=10;const el=document.getElementById(\"t\");const i=setInterval(()=>{n--;el.textContent=n;if(n<=0){clearInterval(i);window.close();}},1000);<\/script></body></html>");
224
+ res.end("<html><body><h1>Authentication successful!</h1><p>You can close this tab and return to your AI assistant.</p><p>This tab will auto-close in <span id=\"t\">10</span>s.</p><script>let n=10;const el=document.getElementById(\"t\");const i=setInterval(()=>{n--;el.textContent=n;if(n<=0){clearInterval(i);window.close();}},1000);<\/script></body></html>");
225
225
  clearTimeout(authTimeout);
226
226
  callbackServer.close();
227
227
  resolveToken(tokenData);
@@ -432,6 +432,7 @@ const clearToken = (path, logger = silentLogger) => {
432
432
  //#endregion
433
433
  //#region src/auth/token-store.ts
434
434
  const EXPIRY_SKEW_MS = 6e4;
435
+ const SCHEDULED_REFRESH_MS = 14400 * 1e3;
435
436
  const createAuthRequiredError = (authorizeUrl) => Object.assign(/* @__PURE__ */ new Error("Zendesk authentication required. A browser window should have opened for you to sign in. If it did not, open this URL in your browser, then retry your request:\n" + authorizeUrl), {
436
437
  name: "AuthRequiredError",
437
438
  authorizeUrl
@@ -444,16 +445,18 @@ const createTokenStore = (config, logger = silentLogger) => {
444
445
  let authorizeUrl;
445
446
  let starting;
446
447
  let refreshing;
448
+ let probedUnknownExpiry = false;
447
449
  const persist = (t) => saveToken(tokenPath, t, logger);
448
450
  const setToken = (accessToken, refreshToken) => {
449
451
  token = {
450
452
  accessToken,
451
453
  refreshToken
452
454
  };
455
+ probedUnknownExpiry = false;
453
456
  persist(token);
454
457
  };
455
- const isExpired = (t) => typeof t.expiresAt === "number" && Date.now() >= t.expiresAt - EXPIRY_SKEW_MS;
456
- const tryRefresh = async (current) => {
458
+ const needsRefresh = (t) => typeof t.expiresAt === "number" ? Date.now() >= t.expiresAt - EXPIRY_SKEW_MS : t.refreshToken !== void 0 && !probedUnknownExpiry;
459
+ const tryRefresh = async (current, { dropOnFailure = true } = {}) => {
457
460
  if (!current.refreshToken) return void 0;
458
461
  try {
459
462
  const result = await refreshAccessToken({
@@ -466,13 +469,16 @@ const createTokenStore = (config, logger = silentLogger) => {
466
469
  refreshToken: result.refresh_token ?? current.refreshToken,
467
470
  expiresAt: expiryFrom(result.expires_in)
468
471
  };
472
+ probedUnknownExpiry = true;
469
473
  persist(token);
470
474
  logger.info("oauth_token_refreshed_cached");
471
475
  return token.accessToken;
472
476
  } catch (err) {
473
477
  logger.warn("oauth_token_refresh_failed", { error: err instanceof Error ? err.message : String(err) });
474
- token = void 0;
475
- clearToken(tokenPath, logger);
478
+ if (dropOnFailure) {
479
+ token = void 0;
480
+ clearToken(tokenPath, logger);
481
+ }
476
482
  return;
477
483
  }
478
484
  };
@@ -490,6 +496,7 @@ const createTokenStore = (config, logger = silentLogger) => {
490
496
  refreshToken: result.refresh_token,
491
497
  expiresAt: expiryFrom(result.expires_in)
492
498
  };
499
+ probedUnknownExpiry = true;
493
500
  persist(token);
494
501
  logger.info("oauth_token_cached");
495
502
  }).catch((err) => {
@@ -505,7 +512,8 @@ const createTokenStore = (config, logger = silentLogger) => {
505
512
  });
506
513
  };
507
514
  const getToken = async () => {
508
- if (token && !isExpired(token)) {
515
+ if (refreshing) await refreshing;
516
+ if (token && !needsRefresh(token)) {
509
517
  logger.debug("oauth_token_cache_hit");
510
518
  return token.accessToken;
511
519
  }
@@ -533,10 +541,18 @@ const createTokenStore = (config, logger = silentLogger) => {
533
541
  }
534
542
  logger.info("oauth_token_invalidated");
535
543
  };
544
+ const scheduledRefresh = setInterval(() => {
545
+ if (token?.refreshToken && !refreshing) refreshing = tryRefresh(token, { dropOnFailure: false }).finally(() => {
546
+ refreshing = void 0;
547
+ });
548
+ }, SCHEDULED_REFRESH_MS);
549
+ scheduledRefresh.unref?.();
550
+ const dispose = () => clearInterval(scheduledRefresh);
536
551
  return {
537
552
  getToken,
538
553
  setToken,
539
- invalidate
554
+ invalidate,
555
+ dispose
540
556
  };
541
557
  };
542
558
  //#endregion
@@ -830,6 +846,43 @@ const formatTicket = (ticket) => [
830
846
  `- **Created**: ${ticket.created_at} | **Updated**: ${ticket.updated_at}`,
831
847
  ticket.description ? `\n${ticket.description}` : ""
832
848
  ].filter(Boolean).join("\n");
849
+ const formatConditionValue = (value) => value === null || value === void 0 ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
850
+ const formatSlaPolicy = (policy) => {
851
+ const conditions = [...policy.filter.all.map((c) => `all: ${c.field} ${c.operator} ${formatConditionValue(c.value)}`.trim()), ...policy.filter.any.map((c) => `any: ${c.field} ${c.operator} ${formatConditionValue(c.value)}`.trim())];
852
+ const targets = policy.policy_metrics.map((m) => ` - ${m.priority} / ${m.metric}: ${m.target} min${m.business_hours ? " (business)" : ""}`);
853
+ return [
854
+ `## SLA policy: ${policy.title} (${policy.id})`,
855
+ policy.description ? `- **Description**: ${policy.description}` : "",
856
+ `- **Position**: ${policy.position}`,
857
+ conditions.length > 0 ? `- **Conditions**: ${conditions.join("; ")}` : "",
858
+ targets.length > 0 ? "- **Targets**:" : "",
859
+ ...targets
860
+ ].filter(Boolean).join("\n");
861
+ };
862
+ const minutesUntil = (iso) => {
863
+ const t = Date.parse(iso);
864
+ return Number.isNaN(t) ? null : Math.round((t - Date.now()) / 6e4);
865
+ };
866
+ const formatSlaMetric = (m) => {
867
+ const stage = m.stage ?? "unknown";
868
+ const due = m.breach_at ?? null;
869
+ const parts = [`- **${m.metric}** — ${stage}`];
870
+ if (due) {
871
+ const remaining = minutesUntil(due);
872
+ if (stage === "paused" || stage === "achieved" || stage === "fulfilled" || remaining == null) parts.push(`due ${due}`);
873
+ else if (remaining < 0) parts.push(`due ${due} — breached (${Math.abs(remaining)} min overdue)`);
874
+ else parts.push(`due ${due} — ${remaining} min remaining`);
875
+ }
876
+ return parts.join("; ");
877
+ };
878
+ const formatSlaBlock = (entry) => {
879
+ if (!entry?.policy_metrics || entry.policy_metrics.length === 0) return "";
880
+ const lines = ["### SLA"];
881
+ const futureBreaches = entry.policy_metrics.map((m) => m.breach_at).map((d) => d ? Date.parse(d) : NaN).filter((t) => !Number.isNaN(t) && t > Date.now());
882
+ if (futureBreaches.length > 0) lines.push(`- **Next breach**: ${new Date(Math.min(...futureBreaches)).toISOString()}`);
883
+ for (const m of entry.policy_metrics) lines.push(formatSlaMetric(m));
884
+ return `\n\n${lines.join("\n")}`;
885
+ };
833
886
  const formatComment = (comment) => {
834
887
  const lines = [`### ${comment.public ? "Public comment" : "Internal note"} by ${comment.author_id}`, `*${comment.created_at}*`];
835
888
  if (comment.attachments?.length) {
@@ -1891,6 +1944,25 @@ const collectAttachmentBlocks = async (subdomain, token, attachments) => {
1891
1944
  }
1892
1945
  return blocks;
1893
1946
  };
1947
+ const fetchTicketSla = async (subdomain, token, ticket) => {
1948
+ const day = ticket.created_at.slice(0, 10);
1949
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return void 0;
1950
+ const shiftDay = (offset) => {
1951
+ const d = /* @__PURE__ */ new Date(`${day}T00:00:00Z`);
1952
+ d.setUTCDate(d.getUTCDate() + offset);
1953
+ return d.toISOString().slice(0, 10);
1954
+ };
1955
+ try {
1956
+ const { results } = await zendeskGet(subdomain, token, "/search", {
1957
+ query: `type:ticket requester:${ticket.requester_id} created>${shiftDay(-1)} created<${shiftDay(1)}`,
1958
+ include: "tickets(slas)",
1959
+ ...buildOffsetParams(100, 1)
1960
+ });
1961
+ return (results ?? []).find((r) => r.id === ticket.id)?.slas;
1962
+ } catch {
1963
+ return;
1964
+ }
1965
+ };
1894
1966
  const createTicketTools = (ctx) => {
1895
1967
  const { subdomain, getToken } = ctx;
1896
1968
  return [
@@ -1899,7 +1971,7 @@ const createTicketTools = (ctx) => {
1899
1971
  namespace: "tickets",
1900
1972
  readOnly: true,
1901
1973
  title: "Get Zendesk Ticket",
1902
- description: "Retrieve a Zendesk ticket by ID, including its comments if requested. Returns ticket details (subject, status, priority, assignee, tags, description) and optionally all comments/internal notes.",
1974
+ 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.",
1903
1975
  inputSchema: z.object({
1904
1976
  ticket_id: z.number().int().describe("Ticket ID"),
1905
1977
  include_comments: z.boolean().default(false).describe("Include ticket comments")
@@ -1914,7 +1986,7 @@ const createTicketTools = (ctx) => {
1914
1986
  const { ticket_id, include_comments } = params;
1915
1987
  const token = await getToken();
1916
1988
  const { ticket } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}`);
1917
- let text = formatTicket(ticket);
1989
+ let text = formatTicket(ticket) + formatSlaBlock(await fetchTicketSla(subdomain, token, ticket));
1918
1990
  if (include_comments) {
1919
1991
  const { comments } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments`);
1920
1992
  text += `\n\n---\n# Comments\n\n${comments.map(formatComment).join("\n\n")}`;
@@ -1970,7 +2042,7 @@ const createTicketTools = (ctx) => {
1970
2042
  namespace: "tickets",
1971
2043
  readOnly: true,
1972
2044
  title: "Search Zendesk Tickets",
1973
- description: "Search tickets using Zendesk query syntax (e.g., \"status:open assignee:me\", \"priority:urgent type:incident\"). Returns total count.",
2045
+ description: "Search tickets using Zendesk query syntax, returning each result with its live SLA state (per-metric stage and breach countdown) when an SLA policy applies. Examples: \"status:open assignee:me\", \"priority:urgent type:incident\". Returns total count, so queue triage like \"breaching today\" works without a per-ticket fetch.",
1974
2046
  inputSchema: z.object({
1975
2047
  query: z.string().min(1).describe("Zendesk search query string"),
1976
2048
  per_page: z.number().int().min(1).max(100).default(100).describe("Results per page"),
@@ -1986,11 +2058,13 @@ const createTicketTools = (ctx) => {
1986
2058
  const { query, per_page, page } = params;
1987
2059
  const response = await zendeskGet(subdomain, await getToken(), "/search", {
1988
2060
  query: `type:ticket ${query}`,
2061
+ include: "tickets(slas)",
1989
2062
  ...buildOffsetParams(per_page, page)
1990
2063
  });
2064
+ const formatTicketWithSla = (ticket) => formatTicket(ticket) + formatSlaBlock(ticket.slas);
1991
2065
  return { content: [{
1992
2066
  type: "text",
1993
- text: formatList(response.results ?? [], formatTicket, extractSearchPaginationMeta(response, per_page, page))
2067
+ text: formatList(response.results ?? [], formatTicketWithSla, extractSearchPaginationMeta(response, per_page, page))
1994
2068
  }] };
1995
2069
  }
1996
2070
  },
@@ -2231,6 +2305,43 @@ const createTicketTools = (ctx) => {
2231
2305
  text: `Tags updated on ticket #${ticket_id}. Current: ${updated.tags.join(", ") || "none"}`
2232
2306
  }] };
2233
2307
  }
2308
+ },
2309
+ {
2310
+ name: "list_sla_policies",
2311
+ namespace: "tickets",
2312
+ readOnly: true,
2313
+ title: "List SLA Policies",
2314
+ description: "List the configured SLA policies with their filter conditions and per-priority reply/resolution targets. Use this to explain why a given target applies to a ticket and to reconstruct deadlines deterministically instead of hard-coding the policy matrix. Requires an admin token (or a custom role granted the SLA-management permission); a standard agent token gets 403 here, though it can still read live per-ticket SLA via get_ticket / search_tickets.",
2315
+ inputSchema: z.object({
2316
+ per_page: z.number().int().min(1).max(100).default(100).describe("Results per page"),
2317
+ page: z.number().int().min(1).default(1).describe("Page number")
2318
+ }),
2319
+ annotations: {
2320
+ readOnlyHint: true,
2321
+ destructiveHint: false,
2322
+ idempotentHint: true,
2323
+ openWorldHint: true
2324
+ },
2325
+ handler: async (params) => {
2326
+ const { per_page, page } = params;
2327
+ const token = await getToken();
2328
+ let response;
2329
+ try {
2330
+ response = await zendeskGet(subdomain, token, "/slas/policies", buildOffsetParams(per_page, page));
2331
+ } catch (error) {
2332
+ if (error instanceof ZendeskApiError && error.status === 403) throw new Error("list_sla_policies reads SLA policy *configuration* (GET /slas/policies), which Zendesk restricts to admins (or a custom role granted the SLA-management permission). The current token lacks that permission (HTTP 403). This does not affect live SLA on tickets: per-metric SLA stage and breach countdown are available to any agent via get_ticket and search_tickets -- use those for triage and prioritization.");
2333
+ throw error;
2334
+ }
2335
+ const policies = response.sla_policies ?? [];
2336
+ return { content: [{
2337
+ type: "text",
2338
+ text: formatList(policies, formatSlaPolicy, response.count != null ? extractSearchPaginationMeta(response, per_page, page) : {
2339
+ count: policies.length,
2340
+ has_more: false,
2341
+ after_cursor: null
2342
+ })
2343
+ }] };
2344
+ }
2234
2345
  }
2235
2346
  ];
2236
2347
  };
@@ -2376,6 +2487,13 @@ const createAllTools = (ctx) => [
2376
2487
  * refreshes/re-authenticates instead of replaying a revoked token. The callback
2377
2488
  * is omitted only where there is nothing to invalidate (e.g. HTTP per-session
2378
2489
  * bearer, owned by the client).
2490
+ *
2491
+ * Client-visible behaviour on an in-flight revocation: the 401 is a *backstop*,
2492
+ * not a transparent retry. The current call still surfaces the error; recovery
2493
+ * happens on the *next* call, whose `getToken` sees the invalidated token and
2494
+ * silently refreshes (or falls back to browser re-auth if the refresh token is
2495
+ * also dead). Proactive refresh keeps this path rare — it only fires when a
2496
+ * token is revoked between the pre-call refresh check and the request.
2379
2497
  */
2380
2498
  const runHandler = async (def, params, onUnauthorized) => {
2381
2499
  try {