@fruggr/zendesk-mcp-server 2.2.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 |
@@ -651,3 +687,7 @@ Every PR is reviewed automatically by [CodeRabbit](https://www.coderabbit.ai) in
651
687
  ## License
652
688
 
653
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
@@ -846,6 +846,43 @@ const formatTicket = (ticket) => [
846
846
  `- **Created**: ${ticket.created_at} | **Updated**: ${ticket.updated_at}`,
847
847
  ticket.description ? `\n${ticket.description}` : ""
848
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
+ };
849
886
  const formatComment = (comment) => {
850
887
  const lines = [`### ${comment.public ? "Public comment" : "Internal note"} by ${comment.author_id}`, `*${comment.created_at}*`];
851
888
  if (comment.attachments?.length) {
@@ -1907,6 +1944,25 @@ const collectAttachmentBlocks = async (subdomain, token, attachments) => {
1907
1944
  }
1908
1945
  return blocks;
1909
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
+ };
1910
1966
  const createTicketTools = (ctx) => {
1911
1967
  const { subdomain, getToken } = ctx;
1912
1968
  return [
@@ -1915,7 +1971,7 @@ const createTicketTools = (ctx) => {
1915
1971
  namespace: "tickets",
1916
1972
  readOnly: true,
1917
1973
  title: "Get Zendesk Ticket",
1918
- 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.",
1919
1975
  inputSchema: z.object({
1920
1976
  ticket_id: z.number().int().describe("Ticket ID"),
1921
1977
  include_comments: z.boolean().default(false).describe("Include ticket comments")
@@ -1930,7 +1986,7 @@ const createTicketTools = (ctx) => {
1930
1986
  const { ticket_id, include_comments } = params;
1931
1987
  const token = await getToken();
1932
1988
  const { ticket } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}`);
1933
- let text = formatTicket(ticket);
1989
+ let text = formatTicket(ticket) + formatSlaBlock(await fetchTicketSla(subdomain, token, ticket));
1934
1990
  if (include_comments) {
1935
1991
  const { comments } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments`);
1936
1992
  text += `\n\n---\n# Comments\n\n${comments.map(formatComment).join("\n\n")}`;
@@ -1986,7 +2042,7 @@ const createTicketTools = (ctx) => {
1986
2042
  namespace: "tickets",
1987
2043
  readOnly: true,
1988
2044
  title: "Search Zendesk Tickets",
1989
- 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.",
1990
2046
  inputSchema: z.object({
1991
2047
  query: z.string().min(1).describe("Zendesk search query string"),
1992
2048
  per_page: z.number().int().min(1).max(100).default(100).describe("Results per page"),
@@ -2002,11 +2058,13 @@ const createTicketTools = (ctx) => {
2002
2058
  const { query, per_page, page } = params;
2003
2059
  const response = await zendeskGet(subdomain, await getToken(), "/search", {
2004
2060
  query: `type:ticket ${query}`,
2061
+ include: "tickets(slas)",
2005
2062
  ...buildOffsetParams(per_page, page)
2006
2063
  });
2064
+ const formatTicketWithSla = (ticket) => formatTicket(ticket) + formatSlaBlock(ticket.slas);
2007
2065
  return { content: [{
2008
2066
  type: "text",
2009
- text: formatList(response.results ?? [], formatTicket, extractSearchPaginationMeta(response, per_page, page))
2067
+ text: formatList(response.results ?? [], formatTicketWithSla, extractSearchPaginationMeta(response, per_page, page))
2010
2068
  }] };
2011
2069
  }
2012
2070
  },
@@ -2247,6 +2305,43 @@ const createTicketTools = (ctx) => {
2247
2305
  text: `Tags updated on ticket #${ticket_id}. Current: ${updated.tags.join(", ") || "none"}`
2248
2306
  }] };
2249
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
+ }
2250
2345
  }
2251
2346
  ];
2252
2347
  };