@fruggr/zendesk-mcp-server 2.2.0 → 2.3.1
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 +165 -31
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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
|
@@ -34,7 +34,7 @@ const MCP_LEVEL = {
|
|
|
34
34
|
warn: "warning",
|
|
35
35
|
error: "error"
|
|
36
36
|
};
|
|
37
|
-
const REDACTED_KEYS = new Set([
|
|
37
|
+
const REDACTED_KEYS = /* @__PURE__ */ new Set([
|
|
38
38
|
"token",
|
|
39
39
|
"accesstoken",
|
|
40
40
|
"refreshtoken",
|
|
@@ -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) {
|
|
@@ -915,10 +952,10 @@ const buildOffsetParams = (perPage, page) => {
|
|
|
915
952
|
if (page && page > 1) params["page"] = String(page);
|
|
916
953
|
return params;
|
|
917
954
|
};
|
|
918
|
-
const extractPaginationMeta = (response) => ({
|
|
955
|
+
const extractPaginationMeta = (response, itemCount) => ({
|
|
919
956
|
has_more: response.meta?.has_more ?? response.next_page != null,
|
|
920
957
|
after_cursor: response.meta?.after_cursor ?? null,
|
|
921
|
-
count: response.count ??
|
|
958
|
+
count: response.count ?? itemCount
|
|
922
959
|
});
|
|
923
960
|
const extractSearchPaginationMeta = (response, perPage, page) => {
|
|
924
961
|
const count = response.count ?? 0;
|
|
@@ -947,13 +984,15 @@ const fetchTopology = async (subdomain, token) => {
|
|
|
947
984
|
zendeskGet(subdomain, token, "/guide/permission_groups"),
|
|
948
985
|
zendeskGet(subdomain, token, "/users/me")
|
|
949
986
|
]);
|
|
987
|
+
const categories = categoriesRes.categories ?? [];
|
|
988
|
+
const sections = sectionsRes.sections ?? [];
|
|
950
989
|
return {
|
|
951
990
|
subdomain,
|
|
952
991
|
locales,
|
|
953
|
-
categories
|
|
954
|
-
sections
|
|
955
|
-
sectionsHasMore: extractPaginationMeta(sectionsRes).has_more,
|
|
956
|
-
categoriesHasMore: extractPaginationMeta(categoriesRes).has_more,
|
|
992
|
+
categories,
|
|
993
|
+
sections,
|
|
994
|
+
sectionsHasMore: extractPaginationMeta(sectionsRes, sections.length).has_more,
|
|
995
|
+
categoriesHasMore: extractPaginationMeta(categoriesRes, categories.length).has_more,
|
|
957
996
|
userSegments: segmentsRes.user_segments ?? [],
|
|
958
997
|
permissionGroups: permsRes.permission_groups ?? [],
|
|
959
998
|
currentUser: meRes.user
|
|
@@ -1055,7 +1094,7 @@ const groupByNamespace = (tools) => {
|
|
|
1055
1094
|
};
|
|
1056
1095
|
//#endregion
|
|
1057
1096
|
//#region src/utils/article-sections.ts
|
|
1058
|
-
const HEADING_LEVELS = new Set([
|
|
1097
|
+
const HEADING_LEVELS = /* @__PURE__ */ new Set([
|
|
1059
1098
|
"h1",
|
|
1060
1099
|
"h2",
|
|
1061
1100
|
"h3"
|
|
@@ -1242,9 +1281,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1242
1281
|
handler: async (params) => {
|
|
1243
1282
|
const { locale, page_size, cursor } = params;
|
|
1244
1283
|
const response = await helpCenterGet(subdomain, await getToken(), locale ? `/${locale}/categories` : "/categories", buildCursorParams(page_size, cursor));
|
|
1284
|
+
const categories = response.categories ?? [];
|
|
1245
1285
|
return { content: [{
|
|
1246
1286
|
type: "text",
|
|
1247
|
-
text: formatList(
|
|
1287
|
+
text: formatList(categories, formatCategory, extractPaginationMeta(response, categories.length))
|
|
1248
1288
|
}] };
|
|
1249
1289
|
}
|
|
1250
1290
|
},
|
|
@@ -1269,9 +1309,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1269
1309
|
handler: async (params) => {
|
|
1270
1310
|
const { category_id, locale, page_size, cursor } = params;
|
|
1271
1311
|
const response = await helpCenterGet(subdomain, await getToken(), category_id && locale ? `/${locale}/categories/${category_id}/sections` : category_id ? `/categories/${category_id}/sections` : locale ? `/${locale}/sections` : "/sections", buildCursorParams(page_size, cursor));
|
|
1312
|
+
const sections = response.sections ?? [];
|
|
1272
1313
|
return { content: [{
|
|
1273
1314
|
type: "text",
|
|
1274
|
-
text: formatList(
|
|
1315
|
+
text: formatList(sections, formatSection, extractPaginationMeta(response, sections.length))
|
|
1275
1316
|
}] };
|
|
1276
1317
|
}
|
|
1277
1318
|
},
|
|
@@ -1284,8 +1325,8 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1284
1325
|
inputSchema: z.object({
|
|
1285
1326
|
section_id: z.number().int().optional(),
|
|
1286
1327
|
locale: z.string().optional(),
|
|
1287
|
-
page_size: z.number().int().min(1).max(100).default(100),
|
|
1288
|
-
cursor: z.string().optional(),
|
|
1328
|
+
page_size: z.number().int().min(1).max(100).default(100).describe("Articles per page (1-100, default 100)."),
|
|
1329
|
+
cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page."),
|
|
1289
1330
|
sort_by: z.enum([
|
|
1290
1331
|
"created_at",
|
|
1291
1332
|
"updated_at",
|
|
@@ -1312,14 +1353,14 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1312
1353
|
const articles = response.articles ?? [];
|
|
1313
1354
|
if (!include_translations) return { content: [{
|
|
1314
1355
|
type: "text",
|
|
1315
|
-
text: formatList(articles, formatArticleSummary, extractPaginationMeta(response))
|
|
1356
|
+
text: formatList(articles, formatArticleSummary, extractPaginationMeta(response, articles.length))
|
|
1316
1357
|
}] };
|
|
1317
1358
|
const formatted = await Promise.all(articles.map(async (article) => {
|
|
1318
1359
|
const { translations } = await helpCenterGet(subdomain, token, `/articles/${article.id}/translations`);
|
|
1319
1360
|
const locales = translations.map((t) => t.locale).join(", ");
|
|
1320
1361
|
return `${formatArticleSummary(article)}\n- **Translations**: ${locales}`;
|
|
1321
1362
|
}));
|
|
1322
|
-
const meta = extractPaginationMeta(response);
|
|
1363
|
+
const meta = extractPaginationMeta(response, articles.length);
|
|
1323
1364
|
return { content: [{
|
|
1324
1365
|
type: "text",
|
|
1325
1366
|
text: truncateIfNeeded([meta.count ? `Results: ${meta.count}${meta.has_more ? ` | More available (cursor: ${meta.after_cursor})` : ""}` : "", ...formatted].filter(Boolean).join("\n\n"))
|
|
@@ -1907,6 +1948,25 @@ const collectAttachmentBlocks = async (subdomain, token, attachments) => {
|
|
|
1907
1948
|
}
|
|
1908
1949
|
return blocks;
|
|
1909
1950
|
};
|
|
1951
|
+
const fetchTicketSla = async (subdomain, token, ticket) => {
|
|
1952
|
+
const day = ticket.created_at.slice(0, 10);
|
|
1953
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return void 0;
|
|
1954
|
+
const shiftDay = (offset) => {
|
|
1955
|
+
const d = /* @__PURE__ */ new Date(`${day}T00:00:00Z`);
|
|
1956
|
+
d.setUTCDate(d.getUTCDate() + offset);
|
|
1957
|
+
return d.toISOString().slice(0, 10);
|
|
1958
|
+
};
|
|
1959
|
+
try {
|
|
1960
|
+
const { results } = await zendeskGet(subdomain, token, "/search", {
|
|
1961
|
+
query: `type:ticket requester:${ticket.requester_id} created>${shiftDay(-1)} created<${shiftDay(1)}`,
|
|
1962
|
+
include: "tickets(slas)",
|
|
1963
|
+
...buildOffsetParams(100, 1)
|
|
1964
|
+
});
|
|
1965
|
+
return (results ?? []).find((r) => r.id === ticket.id)?.slas;
|
|
1966
|
+
} catch {
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
};
|
|
1910
1970
|
const createTicketTools = (ctx) => {
|
|
1911
1971
|
const { subdomain, getToken } = ctx;
|
|
1912
1972
|
return [
|
|
@@ -1915,7 +1975,7 @@ const createTicketTools = (ctx) => {
|
|
|
1915
1975
|
namespace: "tickets",
|
|
1916
1976
|
readOnly: true,
|
|
1917
1977
|
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.",
|
|
1978
|
+
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
1979
|
inputSchema: z.object({
|
|
1920
1980
|
ticket_id: z.number().int().describe("Ticket ID"),
|
|
1921
1981
|
include_comments: z.boolean().default(false).describe("Include ticket comments")
|
|
@@ -1930,7 +1990,7 @@ const createTicketTools = (ctx) => {
|
|
|
1930
1990
|
const { ticket_id, include_comments } = params;
|
|
1931
1991
|
const token = await getToken();
|
|
1932
1992
|
const { ticket } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}`);
|
|
1933
|
-
let text = formatTicket(ticket);
|
|
1993
|
+
let text = formatTicket(ticket) + formatSlaBlock(await fetchTicketSla(subdomain, token, ticket));
|
|
1934
1994
|
if (include_comments) {
|
|
1935
1995
|
const { comments } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments`);
|
|
1936
1996
|
text += `\n\n---\n# Comments\n\n${comments.map(formatComment).join("\n\n")}`;
|
|
@@ -1986,7 +2046,7 @@ const createTicketTools = (ctx) => {
|
|
|
1986
2046
|
namespace: "tickets",
|
|
1987
2047
|
readOnly: true,
|
|
1988
2048
|
title: "Search Zendesk Tickets",
|
|
1989
|
-
description: "Search tickets using Zendesk query syntax (
|
|
2049
|
+
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
2050
|
inputSchema: z.object({
|
|
1991
2051
|
query: z.string().min(1).describe("Zendesk search query string"),
|
|
1992
2052
|
per_page: z.number().int().min(1).max(100).default(100).describe("Results per page"),
|
|
@@ -2002,11 +2062,13 @@ const createTicketTools = (ctx) => {
|
|
|
2002
2062
|
const { query, per_page, page } = params;
|
|
2003
2063
|
const response = await zendeskGet(subdomain, await getToken(), "/search", {
|
|
2004
2064
|
query: `type:ticket ${query}`,
|
|
2065
|
+
include: "tickets(slas)",
|
|
2005
2066
|
...buildOffsetParams(per_page, page)
|
|
2006
2067
|
});
|
|
2068
|
+
const formatTicketWithSla = (ticket) => formatTicket(ticket) + formatSlaBlock(ticket.slas);
|
|
2007
2069
|
return { content: [{
|
|
2008
2070
|
type: "text",
|
|
2009
|
-
text: formatList(response.results ?? [],
|
|
2071
|
+
text: formatList(response.results ?? [], formatTicketWithSla, extractSearchPaginationMeta(response, per_page, page))
|
|
2010
2072
|
}] };
|
|
2011
2073
|
}
|
|
2012
2074
|
},
|
|
@@ -2171,10 +2233,10 @@ const createTicketTools = (ctx) => {
|
|
|
2171
2233
|
namespace: "tickets",
|
|
2172
2234
|
readOnly: true,
|
|
2173
2235
|
title: "List Zendesk Tickets",
|
|
2174
|
-
description: "List tickets with cursor-based pagination, sorted by most recently updated.",
|
|
2236
|
+
description: "List tickets with cursor-based pagination, sorted by most recently updated. Page size is controlled by page_size (not per_page, which is the offset-based parameter used by search_tickets); paginate by passing the returned cursor.",
|
|
2175
2237
|
inputSchema: z.object({
|
|
2176
|
-
page_size: z.number().int().min(1).max(100).default(100),
|
|
2177
|
-
cursor: z.string().optional().describe("Pagination cursor")
|
|
2238
|
+
page_size: z.number().int().min(1).max(100).default(100).describe("Tickets per page (1-100, default 100)."),
|
|
2239
|
+
cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page.")
|
|
2178
2240
|
}),
|
|
2179
2241
|
annotations: {
|
|
2180
2242
|
readOnlyHint: true,
|
|
@@ -2185,9 +2247,10 @@ const createTicketTools = (ctx) => {
|
|
|
2185
2247
|
handler: async (params) => {
|
|
2186
2248
|
const { page_size, cursor } = params;
|
|
2187
2249
|
const response = await zendeskGet(subdomain, await getToken(), "/tickets", buildCursorParams(page_size, cursor));
|
|
2250
|
+
const tickets = response.tickets ?? [];
|
|
2188
2251
|
return { content: [{
|
|
2189
2252
|
type: "text",
|
|
2190
|
-
text: formatList(
|
|
2253
|
+
text: formatList(tickets, formatTicket, extractPaginationMeta(response, tickets.length))
|
|
2191
2254
|
}] };
|
|
2192
2255
|
}
|
|
2193
2256
|
},
|
|
@@ -2247,6 +2310,43 @@ const createTicketTools = (ctx) => {
|
|
|
2247
2310
|
text: `Tags updated on ticket #${ticket_id}. Current: ${updated.tags.join(", ") || "none"}`
|
|
2248
2311
|
}] };
|
|
2249
2312
|
}
|
|
2313
|
+
},
|
|
2314
|
+
{
|
|
2315
|
+
name: "list_sla_policies",
|
|
2316
|
+
namespace: "tickets",
|
|
2317
|
+
readOnly: true,
|
|
2318
|
+
title: "List SLA Policies",
|
|
2319
|
+
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.",
|
|
2320
|
+
inputSchema: z.object({
|
|
2321
|
+
per_page: z.number().int().min(1).max(100).default(100).describe("Results per page"),
|
|
2322
|
+
page: z.number().int().min(1).default(1).describe("Page number")
|
|
2323
|
+
}),
|
|
2324
|
+
annotations: {
|
|
2325
|
+
readOnlyHint: true,
|
|
2326
|
+
destructiveHint: false,
|
|
2327
|
+
idempotentHint: true,
|
|
2328
|
+
openWorldHint: true
|
|
2329
|
+
},
|
|
2330
|
+
handler: async (params) => {
|
|
2331
|
+
const { per_page, page } = params;
|
|
2332
|
+
const token = await getToken();
|
|
2333
|
+
let response;
|
|
2334
|
+
try {
|
|
2335
|
+
response = await zendeskGet(subdomain, token, "/slas/policies", buildOffsetParams(per_page, page));
|
|
2336
|
+
} catch (error) {
|
|
2337
|
+
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.", { cause: error });
|
|
2338
|
+
throw error;
|
|
2339
|
+
}
|
|
2340
|
+
const policies = response.sla_policies ?? [];
|
|
2341
|
+
return { content: [{
|
|
2342
|
+
type: "text",
|
|
2343
|
+
text: formatList(policies, formatSlaPolicy, response.count != null ? extractSearchPaginationMeta(response, per_page, page) : {
|
|
2344
|
+
count: policies.length,
|
|
2345
|
+
has_more: false,
|
|
2346
|
+
after_cursor: null
|
|
2347
|
+
})
|
|
2348
|
+
}] };
|
|
2349
|
+
}
|
|
2250
2350
|
}
|
|
2251
2351
|
];
|
|
2252
2352
|
};
|
|
@@ -2368,9 +2468,10 @@ const createUserTools = (ctx) => {
|
|
|
2368
2468
|
handler: async (params) => {
|
|
2369
2469
|
const { page_size, cursor } = params;
|
|
2370
2470
|
const response = await zendeskGet(subdomain, await getToken(), "/organizations", buildCursorParams(page_size, cursor));
|
|
2471
|
+
const organizations = response.organizations ?? [];
|
|
2371
2472
|
return { content: [{
|
|
2372
2473
|
type: "text",
|
|
2373
|
-
text: formatList(
|
|
2474
|
+
text: formatList(organizations, formatOrganization, extractPaginationMeta(response, organizations.length))
|
|
2374
2475
|
}] };
|
|
2375
2476
|
}
|
|
2376
2477
|
}
|
|
@@ -2385,6 +2486,35 @@ const createAllTools = (ctx) => [
|
|
|
2385
2486
|
...createUserTools(ctx)
|
|
2386
2487
|
];
|
|
2387
2488
|
//#endregion
|
|
2489
|
+
//#region src/utils/validation.ts
|
|
2490
|
+
/**
|
|
2491
|
+
* Build a strict params parser for a tool's input schema, computing the strict
|
|
2492
|
+
* schema and the valid-key list once (at proxy-dispatch construction) rather
|
|
2493
|
+
* than per call.
|
|
2494
|
+
*
|
|
2495
|
+
* Zod objects default to `strip`, which silently drops unknown keys. That hid
|
|
2496
|
+
* #100: a caller passing `per_page` to list_tickets (whose parameter is
|
|
2497
|
+
* `page_size`) had the key dropped, so `page_size` fell back to its default and
|
|
2498
|
+
* a large unpaginated page came back. The returned parser rejects unknown keys
|
|
2499
|
+
* and rewrites the raw Zod error into a message that names the offending keys
|
|
2500
|
+
* and lists the valid parameters so a mistyped/misremembered name fails loudly.
|
|
2501
|
+
*
|
|
2502
|
+
* Used on the proxy dispatch path (namespace/single modes), where this code
|
|
2503
|
+
* owns the parse. In `all` mode the SDK validates against the strict schema we
|
|
2504
|
+
* register and produces its own (also explicit) "Unrecognized key" message.
|
|
2505
|
+
*/
|
|
2506
|
+
const createStrictParamsParser = (schema) => {
|
|
2507
|
+
const strict = schema.strict();
|
|
2508
|
+
const validKeys = Object.keys(schema.shape).sort().join(", ");
|
|
2509
|
+
return (params) => {
|
|
2510
|
+
const result = strict.safeParse(params);
|
|
2511
|
+
if (result.success) return result.data;
|
|
2512
|
+
const unknownKeys = result.error.issues.filter((issue) => issue.code === "unrecognized_keys").flatMap((issue) => issue.keys ?? []);
|
|
2513
|
+
if (unknownKeys.length > 0) throw new Error(`Unknown parameter(s): ${unknownKeys.join(", ")}. Valid parameters: ${validKeys || "(none)"}.`);
|
|
2514
|
+
throw result.error;
|
|
2515
|
+
};
|
|
2516
|
+
};
|
|
2517
|
+
//#endregion
|
|
2388
2518
|
//#region src/server.ts
|
|
2389
2519
|
/**
|
|
2390
2520
|
* Invoke a tool handler, notifying `onUnauthorized` when Zendesk rejects the
|
|
@@ -2436,15 +2566,19 @@ const aggregateAnnotations = (tools) => ({
|
|
|
2436
2566
|
});
|
|
2437
2567
|
const buildProxyDispatch = (tools, onUnauthorized) => {
|
|
2438
2568
|
const operationNames = tools.map((t) => t.name);
|
|
2439
|
-
const localHandlers = new Map(tools.map((t) => [t.name,
|
|
2569
|
+
const localHandlers = new Map(tools.map((t) => [t.name, {
|
|
2570
|
+
def: t,
|
|
2571
|
+
parseParams: createStrictParamsParser(t.inputSchema)
|
|
2572
|
+
}]));
|
|
2440
2573
|
return async (args) => {
|
|
2441
2574
|
const { operation, params } = args;
|
|
2442
|
-
const
|
|
2443
|
-
if (!
|
|
2575
|
+
const entry = localHandlers.get(operation);
|
|
2576
|
+
if (!entry) return { content: [{
|
|
2444
2577
|
type: "text",
|
|
2445
2578
|
text: `Unknown operation "${operation}". Available: ${operationNames.join(", ")}`
|
|
2446
2579
|
}] };
|
|
2447
|
-
|
|
2580
|
+
const validated = entry.parseParams(params);
|
|
2581
|
+
return runHandler(entry.def, validated, onUnauthorized);
|
|
2448
2582
|
};
|
|
2449
2583
|
};
|
|
2450
2584
|
const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnauthorized) => {
|
|
@@ -2487,7 +2621,7 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
|
|
|
2487
2621
|
for (const tool of filteredTools) server.registerTool(tool.name, {
|
|
2488
2622
|
title: tool.title,
|
|
2489
2623
|
description: tool.description,
|
|
2490
|
-
inputSchema: tool.inputSchema.
|
|
2624
|
+
inputSchema: tool.inputSchema.strict(),
|
|
2491
2625
|
annotations: tool.annotations
|
|
2492
2626
|
}, async (params) => runHandler(tool, params, onUnauthorized));
|
|
2493
2627
|
break;
|
|
@@ -2523,7 +2657,7 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
|
|
|
2523
2657
|
};
|
|
2524
2658
|
//#endregion
|
|
2525
2659
|
//#region src/transports/http.ts
|
|
2526
|
-
const WILDCARD_HOSTS = new Set([
|
|
2660
|
+
const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
|
|
2527
2661
|
"0.0.0.0",
|
|
2528
2662
|
"::",
|
|
2529
2663
|
"*"
|
|
@@ -2542,12 +2676,12 @@ const CORS_ALLOWED_METHODS = "GET, POST, DELETE, OPTIONS";
|
|
|
2542
2676
|
const CORS_ALLOWED_HEADERS = "Authorization, Content-Type, Accept, mcp-session-id, mcp-protocol-version, last-event-id";
|
|
2543
2677
|
const CORS_EXPOSE_HEADERS = "mcp-session-id";
|
|
2544
2678
|
const CORS_MAX_AGE = "600";
|
|
2545
|
-
const LOCALHOST_HOSTNAMES = new Set([
|
|
2679
|
+
const LOCALHOST_HOSTNAMES = /* @__PURE__ */ new Set([
|
|
2546
2680
|
"localhost",
|
|
2547
2681
|
"127.0.0.1",
|
|
2548
2682
|
"[::1]"
|
|
2549
2683
|
]);
|
|
2550
|
-
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
|
|
2684
|
+
const ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
|
|
2551
2685
|
/**
|
|
2552
2686
|
* Returns the origin string to reflect in `Access-Control-Allow-Origin`, or
|
|
2553
2687
|
* `undefined` if the origin is not allowed.
|