@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 +51 -11
- package/dist/index.js +99 -4
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -7,20 +7,55 @@
|
|
|
7
7
|
[](https://renovatebot.com)
|
|
8
8
|
[](https://github.com/semantic-release/semantic-release)
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 ?? [],
|
|
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
|
};
|