@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 +56 -13
- package/dist/index.js +129 -11
- 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 |
|
|
@@ -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
|
|
228
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
475
|
-
|
|
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 (
|
|
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 (
|
|
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 ?? [],
|
|
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 {
|