@fruggr/zendesk-mcp-server 2.4.0 → 2.6.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 +8 -6
- package/dist/index.js +134 -83
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
[](https://renovatebot.com)
|
|
9
9
|
[](https://github.com/semantic-release/semantic-release)
|
|
10
10
|
|
|
11
|
-
**Bring Zendesk
|
|
12
|
-
[Model Context Protocol](https://modelcontextprotocol.io) (MCP) server
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
**Bring Zendesk deep into your AI assistant.** A
|
|
12
|
+
[Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for a
|
|
13
|
+
two-way integration: find answers in the Help Center, **draft, update and
|
|
14
|
+
translate** articles (keeping languages in sync), and **manage Support tickets**
|
|
15
|
+
end to end — comments, triage and image attachments — all in plain language,
|
|
16
|
+
**without switching apps**.
|
|
15
17
|
|
|
16
18
|
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),
|
|
17
19
|
but **vendor-neutral** — it drops into any MCP client (Claude Desktop, Claude
|
|
@@ -131,8 +133,8 @@ zendesk-mcp-server acme --namespace tickets
|
|
|
131
133
|
| `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 |
|
|
132
134
|
| `create_ticket` | Create a new ticket with subject, description, priority, tags... | write |
|
|
133
135
|
| `update_ticket` | Update ticket status, priority, assignee, tags, custom fields | write |
|
|
134
|
-
| `add_private_note` | Add an internal note (not visible to requester) | write |
|
|
135
|
-
| `add_public_comment` | Add a public comment (visible to requester) | write |
|
|
136
|
+
| `add_private_note` | Add an internal note (not visible to requester), optionally with file attachments | write |
|
|
137
|
+
| `add_public_comment` | Add a public comment (visible to requester), optionally with file attachments | write |
|
|
136
138
|
| `manage_tags` | Add or remove tags on a ticket | write |
|
|
137
139
|
|
|
138
140
|
</details>
|
package/dist/index.js
CHANGED
|
@@ -783,6 +783,24 @@ const fetchZendeskBinary = async (subdomain, token, contentUrl) => {
|
|
|
783
783
|
contentType
|
|
784
784
|
};
|
|
785
785
|
};
|
|
786
|
+
const zendeskUpload = async (subdomain, token, filename, data, contentType, uploadToken) => {
|
|
787
|
+
const params = { filename };
|
|
788
|
+
if (uploadToken) params["token"] = uploadToken;
|
|
789
|
+
const url = buildUrl(getBaseUrl(subdomain), "/uploads", params);
|
|
790
|
+
const response = await fetch(url, {
|
|
791
|
+
method: "POST",
|
|
792
|
+
headers: {
|
|
793
|
+
Authorization: buildAuthHeader(token),
|
|
794
|
+
"Content-Type": contentType
|
|
795
|
+
},
|
|
796
|
+
body: data
|
|
797
|
+
});
|
|
798
|
+
if (!response.ok) {
|
|
799
|
+
const responseBody = await response.text();
|
|
800
|
+
throw new ZendeskApiError(response.status, response.statusText, responseBody);
|
|
801
|
+
}
|
|
802
|
+
return response.json();
|
|
803
|
+
};
|
|
786
804
|
const helpCenterUpload = async (subdomain, token, path, formData) => {
|
|
787
805
|
const url = buildUrl(getHelpCenterBaseUrl(subdomain), path);
|
|
788
806
|
const response = await fetch(url, {
|
|
@@ -942,6 +960,8 @@ const formatList = (items, formatter, meta) => {
|
|
|
942
960
|
};
|
|
943
961
|
//#endregion
|
|
944
962
|
//#region src/utils/pagination.ts
|
|
963
|
+
const PER_PAGE_DESC = "Number of results per page for offset pagination (1-100). Pair with `page` to walk large result sets; the response header reports the total count and whether more pages remain.";
|
|
964
|
+
const PAGE_DESC = "1-based page number for offset pagination. Increment it while keeping `per_page` fixed to fetch subsequent pages; page 1 is the first page.";
|
|
945
965
|
const buildCursorParams = (pageSize, cursor) => {
|
|
946
966
|
const params = { "page[size]": String(pageSize) };
|
|
947
967
|
if (cursor) params["page[after]"] = cursor;
|
|
@@ -1189,6 +1209,7 @@ const markdownToHtml = (markdown) => {
|
|
|
1189
1209
|
};
|
|
1190
1210
|
//#endregion
|
|
1191
1211
|
//#region src/tools/help-center.ts
|
|
1212
|
+
const ARTICLE_ID_DESC = "Article ID — the numeric id of the Help Center article. Obtain it from list_articles or search_articles.";
|
|
1192
1213
|
const largeArticleHint = (body, sectionCount) => {
|
|
1193
1214
|
if (body.length < 3e3 && sectionCount < 4) return null;
|
|
1194
1215
|
return [
|
|
@@ -1208,10 +1229,10 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1208
1229
|
title: "Search Help Center Articles",
|
|
1209
1230
|
description: "Full-text search across Help Center articles (metadata only, no body). Use get_article for full content. Supports locale filtering. Returns total count.",
|
|
1210
1231
|
inputSchema: z.object({
|
|
1211
|
-
query: z.string().min(1).describe("
|
|
1232
|
+
query: z.string().min(1).describe("Full-text query matched against article titles and body. Plain keywords; combine with the locale filter to scope to one language."),
|
|
1212
1233
|
locale: z.string().optional().describe("Filter by locale (e.g., \"en-us\", \"fr\")"),
|
|
1213
|
-
per_page: z.number().int().min(1).max(100).default(100).describe(
|
|
1214
|
-
page: z.number().int().min(1).default(1).describe(
|
|
1234
|
+
per_page: z.number().int().min(1).max(100).default(100).describe(PER_PAGE_DESC),
|
|
1235
|
+
page: z.number().int().min(1).default(1).describe(PAGE_DESC)
|
|
1215
1236
|
}),
|
|
1216
1237
|
annotations: {
|
|
1217
1238
|
readOnlyHint: true,
|
|
@@ -1241,7 +1262,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1241
1262
|
title: "Get Help Center Article",
|
|
1242
1263
|
description: "Retrieve an article by ID with full body content. For large articles, prefer get_article_outline + get_article_section to save tokens. Optionally specify locale for a translated version. Returns body (HTML), metadata, source_locale, and list of available translations.",
|
|
1243
1264
|
inputSchema: z.object({
|
|
1244
|
-
article_id: z.number().int().describe(
|
|
1265
|
+
article_id: z.number().int().describe(ARTICLE_ID_DESC),
|
|
1245
1266
|
locale: z.string().optional().describe("Locale for translated version")
|
|
1246
1267
|
}),
|
|
1247
1268
|
annotations: {
|
|
@@ -1323,8 +1344,8 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1323
1344
|
title: "List Help Center Articles",
|
|
1324
1345
|
description: "List articles (metadata only, no body). Use get_article for full content. Optionally filter by section ID and locale. Supports sort_by (\"title\", \"created_at\", \"updated_at\") and include_translations: true to show available translation locales per article. Note: include_translations must be re-sent on each paginated request.",
|
|
1325
1346
|
inputSchema: z.object({
|
|
1326
|
-
section_id: z.number().int().optional(),
|
|
1327
|
-
locale: z.string().optional(),
|
|
1347
|
+
section_id: z.number().int().optional().describe("Restrict the listing to one section (numeric id from list_sections). Omit to list articles across all sections."),
|
|
1348
|
+
locale: z.string().optional().describe("Restrict to a single locale, e.g. \"en-us\" or \"fr\". Omit for the default locale."),
|
|
1328
1349
|
page_size: z.number().int().min(1).max(100).default(100).describe("Articles per page (1-100, default 100)."),
|
|
1329
1350
|
cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page."),
|
|
1330
1351
|
sort_by: z.enum([
|
|
@@ -1332,8 +1353,8 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1332
1353
|
"updated_at",
|
|
1333
1354
|
"position",
|
|
1334
1355
|
"title"
|
|
1335
|
-
]).default("position").describe("
|
|
1336
|
-
sort_order: z.enum(["asc", "desc"]).default("asc").describe("Sort direction"),
|
|
1356
|
+
]).default("position").describe("Field to sort by; \"position\" (the default) is the manual order set in Guide."),
|
|
1357
|
+
sort_order: z.enum(["asc", "desc"]).default("asc").describe("Sort direction: ascending or descending."),
|
|
1337
1358
|
include_translations: z.boolean().default(false).describe("Include available translation locales per article (causes 1 extra API call per article)")
|
|
1338
1359
|
}),
|
|
1339
1360
|
annotations: {
|
|
@@ -1373,7 +1394,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1373
1394
|
readOnly: true,
|
|
1374
1395
|
title: "List Article Translations",
|
|
1375
1396
|
description: "List all available translations for an article (metadata only, no body: locale, title, draft, updated_at). Use get_article with locale for full translated content.",
|
|
1376
|
-
inputSchema: z.object({ article_id: z.number().int().describe(
|
|
1397
|
+
inputSchema: z.object({ article_id: z.number().int().describe(ARTICLE_ID_DESC) }),
|
|
1377
1398
|
annotations: {
|
|
1378
1399
|
readOnlyHint: true,
|
|
1379
1400
|
destructiveHint: false,
|
|
@@ -1429,11 +1450,11 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1429
1450
|
title: "Update Article Translation",
|
|
1430
1451
|
description: "Update article content (title, body) in a specific locale. For targeted edits on one or a few sections, prefer update_article_section — this tool replaces the FULL body and re-sends the entire article on each write. Use the article's source_locale (from get_article) for the default language, or another locale for translations.",
|
|
1431
1452
|
inputSchema: z.object({
|
|
1432
|
-
article_id: z.number().int(),
|
|
1433
|
-
locale: z.string(),
|
|
1434
|
-
title: z.string().optional(),
|
|
1435
|
-
body: z.string().optional(),
|
|
1436
|
-
draft: z.boolean().optional()
|
|
1453
|
+
article_id: z.number().int().describe("Article ID — the numeric id of the article whose translation to update. Obtain it from list_articles or search_articles."),
|
|
1454
|
+
locale: z.string().describe("Locale of the translation to update, e.g. \"en-us\" or \"fr\". Use the source_locale (from get_article) to edit the default language."),
|
|
1455
|
+
title: z.string().optional().describe("New title for this locale. Omit to leave the current title unchanged."),
|
|
1456
|
+
body: z.string().optional().describe("New full body (HTML) for this locale. Replaces the entire body — for a single-section edit prefer update_article_section. Omit to leave the body unchanged."),
|
|
1457
|
+
draft: z.boolean().optional().describe("When true, keeps this translation as a draft; when false, publishes it.")
|
|
1437
1458
|
}),
|
|
1438
1459
|
annotations: {
|
|
1439
1460
|
readOnlyHint: false,
|
|
@@ -1477,16 +1498,16 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1477
1498
|
title: "Create Help Center Article",
|
|
1478
1499
|
description: "Create a new article in a section. The locale becomes the article's source_locale. Requires a permission_group_id (use list_permission_groups to find available IDs). To add content in other locales afterwards, use create_article_translation.",
|
|
1479
1500
|
inputSchema: z.object({
|
|
1480
|
-
section_id: z.number().int(),
|
|
1481
|
-
title: z.string().min(1),
|
|
1482
|
-
body: z.string().min(1).describe("Article body (
|
|
1501
|
+
section_id: z.number().int().describe("Section that will contain the article (numeric id from list_sections)."),
|
|
1502
|
+
title: z.string().min(1).describe("Title of the new article, in its source locale."),
|
|
1503
|
+
body: z.string().min(1).describe("Article body as HTML (this becomes the source-locale content)."),
|
|
1483
1504
|
permission_group_id: z.number().int().describe("Permission group ID (use list_permission_groups to find it)"),
|
|
1484
1505
|
user_segment_id: z.number().int().optional().describe("User segment ID for visibility (use list_user_segments to find it). Defaults to everyone."),
|
|
1485
1506
|
author_id: z.number().int().optional().describe("Author user ID. Defaults to the authenticated user."),
|
|
1486
1507
|
content_tag_ids: z.array(z.string()).optional().describe("Content tag IDs (use list_content_tags to find them)"),
|
|
1487
|
-
locale: z.string().optional(),
|
|
1488
|
-
draft: z.boolean().default(true),
|
|
1489
|
-
promoted: z.boolean().default(false),
|
|
1508
|
+
locale: z.string().optional().describe("Source locale for the article, e.g. \"en-us\" or \"fr\". Defaults to the Help Center's default locale; becomes the article's source_locale."),
|
|
1509
|
+
draft: z.boolean().default(true).describe("When true (default), the article is created unpublished; set false to publish immediately."),
|
|
1510
|
+
promoted: z.boolean().default(false).describe("When true, marks the article as promoted (featured) in its section. Defaults to false."),
|
|
1490
1511
|
label_names: z.array(z.string()).optional().describe("Label names for search ranking (use list_labels to see existing labels)")
|
|
1491
1512
|
}),
|
|
1492
1513
|
annotations: {
|
|
@@ -1511,15 +1532,15 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1511
1532
|
title: "Update Help Center Article",
|
|
1512
1533
|
description: "Update article metadata only (draft, promoted, labels, tags, visibility, section, sort position, etc.). Does NOT update content (title, body) — use update_article_translation for that.",
|
|
1513
1534
|
inputSchema: z.object({
|
|
1514
|
-
article_id: z.number().int(),
|
|
1515
|
-
draft: z.boolean().optional(),
|
|
1516
|
-
promoted: z.boolean().optional(),
|
|
1517
|
-
label_names: z.array(z.string()).optional().describe("Label names for search ranking"),
|
|
1518
|
-
content_tag_ids: z.array(z.string()).optional().describe("Content tag
|
|
1519
|
-
user_segment_id: z.number().int().optional().describe("User segment
|
|
1520
|
-
author_id: z.number().int().optional().describe("
|
|
1521
|
-
permission_group_id: z.number().int().optional().describe("
|
|
1522
|
-
section_id: z.number().int().optional(),
|
|
1535
|
+
article_id: z.number().int().describe("Article ID — the numeric id of the article to update. Obtain it from list_articles or search_articles."),
|
|
1536
|
+
draft: z.boolean().optional().describe("Set true to unpublish the article (revert to draft) or false to publish it."),
|
|
1537
|
+
promoted: z.boolean().optional().describe("Set true to promote (feature) the article in its section, or false to unpromote it."),
|
|
1538
|
+
label_names: z.array(z.string()).optional().describe("Label names for search ranking (use list_labels to see existing labels)."),
|
|
1539
|
+
content_tag_ids: z.array(z.string()).optional().describe("Content tag ids to attach (use list_content_tags to find them)."),
|
|
1540
|
+
user_segment_id: z.number().int().optional().describe("User segment that controls who can see the article (id from list_user_segments)."),
|
|
1541
|
+
author_id: z.number().int().optional().describe("User id of the article author (from search_users)."),
|
|
1542
|
+
permission_group_id: z.number().int().optional().describe("Guide permission group controlling who can edit (id from list_permission_groups)."),
|
|
1543
|
+
section_id: z.number().int().optional().describe("Move the article to this section (numeric id from list_sections)."),
|
|
1523
1544
|
position: z.number().int().min(0).optional().describe("Sort position within the section (manual ordering only; 0 = first/top). New articles default to position 0. To move an article to the END of its section, set this to one more than the highest current position: read the highest position P from list_articles with sort_by=\"position\", sort_order=\"desc\", then set position = P + 1.")
|
|
1524
1545
|
}),
|
|
1525
1546
|
annotations: {
|
|
@@ -1634,9 +1655,14 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1634
1655
|
},
|
|
1635
1656
|
handler: async (params) => {
|
|
1636
1657
|
const { article_id } = params;
|
|
1658
|
+
const attachments = (await helpCenterGet(subdomain, await getToken(), `/articles/${article_id}/attachments`)).article_attachments ?? [];
|
|
1659
|
+
if (attachments.length === 0) return { content: [{
|
|
1660
|
+
type: "text",
|
|
1661
|
+
text: `No attachments found on article #${article_id}.`
|
|
1662
|
+
}] };
|
|
1637
1663
|
return { content: [{
|
|
1638
1664
|
type: "text",
|
|
1639
|
-
text: formatList(
|
|
1665
|
+
text: formatList(attachments, formatAttachment)
|
|
1640
1666
|
}] };
|
|
1641
1667
|
}
|
|
1642
1668
|
},
|
|
@@ -1647,7 +1673,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1647
1673
|
title: "Get Article Outline",
|
|
1648
1674
|
description: "Return a compact outline of an article (list of sections delimited by h1/h2/h3, with word counts) for the given locale (defaults to source_locale). Includes available translations with their outdated status. Use get_article_section to fetch a specific section.",
|
|
1649
1675
|
inputSchema: z.object({
|
|
1650
|
-
article_id: z.number().int().describe(
|
|
1676
|
+
article_id: z.number().int().describe(ARTICLE_ID_DESC),
|
|
1651
1677
|
locale: z.string().optional().describe("Locale of the body to outline (defaults to article source_locale)")
|
|
1652
1678
|
}),
|
|
1653
1679
|
annotations: {
|
|
@@ -1688,7 +1714,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1688
1714
|
title: "Get Article Section",
|
|
1689
1715
|
description: "Retrieve the content of a single section of an article in a given locale. Use get_article_outline first to discover section indexes. Default format=\"html\" for round-trip safety. Pass format=\"markdown\" only for human review — the Markdown representation is lossy on some structures (<pre> with <br>, tables with multi-<p> cells are kept as raw HTML to limit the damage, but do not round-trip markdown content back through update_article_section).",
|
|
1690
1716
|
inputSchema: z.object({
|
|
1691
|
-
article_id: z.number().int().describe(
|
|
1717
|
+
article_id: z.number().int().describe(ARTICLE_ID_DESC),
|
|
1692
1718
|
locale: z.string().describe("Locale of the body (e.g., \"en-us\", \"fr\")"),
|
|
1693
1719
|
section_index: z.number().int().min(0).describe("0-based index of the section (see get_article_outline)"),
|
|
1694
1720
|
format: z.enum(["html", "markdown"]).default("html").describe("Output format. \"html\" (default) is round-trip safe. \"markdown\" is lossy on some HTML structures — use only for human review, not before update_article_section.")
|
|
@@ -1724,7 +1750,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1724
1750
|
title: "Update Article Section",
|
|
1725
1751
|
description: "Replace the content of a single section of an article in a given locale, keeping the rest of the body intact. The server fetches the current body, replaces the targeted section, and PUTs the full reconstructed body via the Translations API. Default format=\"html\" for fidelity. Use format=\"markdown\" only when you control the input and know it does not rely on structures that round-trip poorly (code blocks with line breaks, tables with multi-paragraph cells). The section heading is preserved and is NOT part of the replaced content.",
|
|
1726
1752
|
inputSchema: z.object({
|
|
1727
|
-
article_id: z.number().int().describe(
|
|
1753
|
+
article_id: z.number().int().describe(ARTICLE_ID_DESC),
|
|
1728
1754
|
locale: z.string().describe("Locale of the translation to update"),
|
|
1729
1755
|
section_index: z.number().int().min(0).describe("0-based index of the section to replace (see get_article_outline)"),
|
|
1730
1756
|
content: z.string().describe("New content for the section (heading excluded). HTML by default, Markdown if format=\"markdown\"."),
|
|
@@ -1758,8 +1784,8 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1758
1784
|
title: "Compare Article Translations",
|
|
1759
1785
|
description: "Compare section structure between two locales of the same article, matched by index. Returns a compact table (one row per section) with status: \"ok\" (both present, source/target word count ratio within 25%), \"different\" (word count ratio diverges by more than 25% — size signal only, NOT a semantic divergence: two locales may legitimately differ in verbosity) or \"missing\" (section absent in target). Useful to spot structurally stale or missing sections; do not interpret \"different\" as an edit regression on its own.",
|
|
1760
1786
|
inputSchema: z.object({
|
|
1761
|
-
article_id: z.number().int().describe(
|
|
1762
|
-
source_locale: z.string().describe("
|
|
1787
|
+
article_id: z.number().int().describe(ARTICLE_ID_DESC),
|
|
1788
|
+
source_locale: z.string().describe("Reference locale to diff against, e.g. \"en-us\". Usually the article source_locale (from get_article)."),
|
|
1763
1789
|
target_locale: z.string().describe("Target locale to compare against source")
|
|
1764
1790
|
}),
|
|
1765
1791
|
annotations: {
|
|
@@ -1810,7 +1836,7 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1810
1836
|
title: "Create Article Attachment",
|
|
1811
1837
|
description: "Upload an attachment to an article. Provide file content as base64-encoded string.",
|
|
1812
1838
|
inputSchema: z.object({
|
|
1813
|
-
article_id: z.number().int().describe(
|
|
1839
|
+
article_id: z.number().int().describe(ARTICLE_ID_DESC),
|
|
1814
1840
|
file_name: z.string().min(1).describe("File name (e.g., \"screenshot.png\")"),
|
|
1815
1841
|
file_base64: z.string().min(1).describe("File content encoded as base64"),
|
|
1816
1842
|
content_type: z.string().default("application/octet-stream").describe("MIME type (e.g., \"image/png\", \"application/pdf\")")
|
|
@@ -1861,9 +1887,9 @@ const createSearchTools = (ctx) => {
|
|
|
1861
1887
|
title: "Zendesk Unified Search",
|
|
1862
1888
|
description: "Search across tickets, users, and organizations. Supports filters like \"type:ticket status:open\", \"type:user role:agent\". Returns total count and paginated results (100 per page). Organization results include name and ID only — use get_organization for full details (tags, domains, details).",
|
|
1863
1889
|
inputSchema: z.object({
|
|
1864
|
-
query: z.string().min(1).describe("Zendesk search query"),
|
|
1865
|
-
per_page: z.number().int().min(1).max(100).default(100).describe(
|
|
1866
|
-
page: z.number().int().min(1).default(1).describe(
|
|
1890
|
+
query: z.string().min(1).describe("Zendesk search query. Supports type/status/role filters (e.g. \"type:ticket status:open\", \"type:user role:agent\") and free text; omit a type filter to search tickets, users and organizations at once."),
|
|
1891
|
+
per_page: z.number().int().min(1).max(100).default(100).describe(PER_PAGE_DESC),
|
|
1892
|
+
page: z.number().int().min(1).default(1).describe(PAGE_DESC)
|
|
1867
1893
|
}),
|
|
1868
1894
|
annotations: {
|
|
1869
1895
|
readOnlyHint: true,
|
|
@@ -1905,7 +1931,10 @@ const fetchAllTicketComments = async (subdomain, token, ticketId) => {
|
|
|
1905
1931
|
let cursor;
|
|
1906
1932
|
let pages = 0;
|
|
1907
1933
|
while (pages < MAX_COMMENT_PAGES) {
|
|
1908
|
-
const response = await zendeskGet(subdomain, token, `/tickets/${ticketId}/comments`,
|
|
1934
|
+
const response = await zendeskGet(subdomain, token, `/tickets/${ticketId}/comments`, {
|
|
1935
|
+
...buildCursorParams(100, cursor),
|
|
1936
|
+
include_inline_images: "true"
|
|
1937
|
+
});
|
|
1909
1938
|
all.push(...response.comments);
|
|
1910
1939
|
pages += 1;
|
|
1911
1940
|
if (!response.meta?.has_more || !response.meta?.after_cursor) break;
|
|
@@ -1969,6 +1998,20 @@ const fetchTicketSla = async (subdomain, token, ticket) => {
|
|
|
1969
1998
|
};
|
|
1970
1999
|
const createTicketTools = (ctx) => {
|
|
1971
2000
|
const { subdomain, getToken } = ctx;
|
|
2001
|
+
const attachmentSchema = z.object({
|
|
2002
|
+
file_name: z.string().min(1).describe("File name, e.g. \"app.log\" or \"screenshot.png\"."),
|
|
2003
|
+
file_base64: z.string().min(1).base64().describe("File content encoded as base64."),
|
|
2004
|
+
content_type: z.string().min(1).default("application/octet-stream").describe("MIME type, e.g. \"text/plain\", \"image/png\", \"application/pdf\".")
|
|
2005
|
+
});
|
|
2006
|
+
const uploadAttachments = async (token, files) => {
|
|
2007
|
+
let uploadToken;
|
|
2008
|
+
for (const file of files) {
|
|
2009
|
+
const { upload } = await zendeskUpload(subdomain, token, file.file_name, Buffer.from(file.file_base64, "base64"), file.content_type, uploadToken);
|
|
2010
|
+
uploadToken = upload.token;
|
|
2011
|
+
}
|
|
2012
|
+
return uploadToken;
|
|
2013
|
+
};
|
|
2014
|
+
const formatAttachmentSuffix = (count) => count ? ` with ${count} attachment(s)` : "";
|
|
1972
2015
|
return [
|
|
1973
2016
|
{
|
|
1974
2017
|
name: "get_ticket",
|
|
@@ -1977,8 +2020,8 @@ const createTicketTools = (ctx) => {
|
|
|
1977
2020
|
title: "Get Zendesk Ticket",
|
|
1978
2021
|
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.",
|
|
1979
2022
|
inputSchema: z.object({
|
|
1980
|
-
ticket_id: z.number().int().describe("Ticket ID"),
|
|
1981
|
-
include_comments: z.boolean().default(false).describe("
|
|
2023
|
+
ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket to fetch. Obtain it from search_tickets or list_tickets."),
|
|
2024
|
+
include_comments: z.boolean().default(false).describe("When true, appends the full public comment and internal note thread to the response. Defaults to false to keep the payload small; enable it when you need the conversation, not just the ticket fields.")
|
|
1982
2025
|
}),
|
|
1983
2026
|
annotations: {
|
|
1984
2027
|
readOnlyHint: true,
|
|
@@ -1992,7 +2035,7 @@ const createTicketTools = (ctx) => {
|
|
|
1992
2035
|
const { ticket } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}`);
|
|
1993
2036
|
let text = formatTicket(ticket) + formatSlaBlock(await fetchTicketSla(subdomain, token, ticket));
|
|
1994
2037
|
if (include_comments) {
|
|
1995
|
-
const { comments } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments
|
|
2038
|
+
const { comments } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments`, { include_inline_images: "true" });
|
|
1996
2039
|
text += `\n\n---\n# Comments\n\n${comments.map(formatComment).join("\n\n")}`;
|
|
1997
2040
|
}
|
|
1998
2041
|
return { content: [{
|
|
@@ -2008,7 +2051,7 @@ const createTicketTools = (ctx) => {
|
|
|
2008
2051
|
title: "Get Zendesk Ticket Attachments",
|
|
2009
2052
|
description: "Retrieve ticket attachments. Images are embedded inline; other files are listed as text references.",
|
|
2010
2053
|
inputSchema: z.object({
|
|
2011
|
-
ticket_id: z.number().int().describe("Ticket ID"),
|
|
2054
|
+
ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket whose attachments to fetch. Obtain it from search_tickets or list_tickets."),
|
|
2012
2055
|
attachment_ids: z.array(z.number().int()).optional().describe("Attachment IDs to fetch directly (e.g. extracted from a previous get_ticket(include_comments=true) call). When provided, skips the comments fetch entirely. When omitted, all attachments of the ticket are returned.")
|
|
2013
2056
|
}),
|
|
2014
2057
|
annotations: {
|
|
@@ -2046,11 +2089,11 @@ const createTicketTools = (ctx) => {
|
|
|
2046
2089
|
namespace: "tickets",
|
|
2047
2090
|
readOnly: true,
|
|
2048
2091
|
title: "Search Zendesk Tickets",
|
|
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
|
|
2092
|
+
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 ticket_type:incident\". Returns total count, so queue triage like \"breaching today\" works without a per-ticket fetch.",
|
|
2050
2093
|
inputSchema: z.object({
|
|
2051
|
-
query: z.string().min(1).describe("Zendesk search query
|
|
2052
|
-
per_page: z.number().int().min(1).max(100).default(100).describe(
|
|
2053
|
-
page: z.number().int().min(1).default(1).describe(
|
|
2094
|
+
query: z.string().min(1).describe("Zendesk ticket search query — field filters like \"status:open\", \"assignee:me\", \"priority:urgent ticket_type:incident\", combined with free text. A \"type:ticket\" scope is added automatically, so filter the ticket kind with ticket_type: (e.g. ticket_type:incident), never type: (which the API rejects here)."),
|
|
2095
|
+
per_page: z.number().int().min(1).max(100).default(100).describe(PER_PAGE_DESC),
|
|
2096
|
+
page: z.number().int().min(1).default(1).describe(PAGE_DESC)
|
|
2054
2097
|
}),
|
|
2055
2098
|
annotations: {
|
|
2056
2099
|
readOnlyHint: true,
|
|
@@ -2079,8 +2122,8 @@ const createTicketTools = (ctx) => {
|
|
|
2079
2122
|
title: "Create Zendesk Ticket",
|
|
2080
2123
|
description: "Create a new Zendesk support ticket with subject, description, and optional priority/type/assignee/tags. The description becomes the first public comment of the ticket, and the new ticket id is returned. After creation, use update_ticket to change status or assignee, add_public_comment or add_private_note to reply, and manage_tags to adjust tags. Look up valid assignee_id / group_id and custom field ids via search_users or your Zendesk admin settings.",
|
|
2081
2124
|
inputSchema: z.object({
|
|
2082
|
-
subject: z.string().min(1).describe("Ticket subject"),
|
|
2083
|
-
description: z.string().min(1).describe("Ticket description"),
|
|
2125
|
+
subject: z.string().min(1).describe("Ticket subject — the short summary line shown in ticket lists and search results."),
|
|
2126
|
+
description: z.string().min(1).describe("Ticket description — the body of the request. It becomes the ticket's first public comment (visible to the requester)."),
|
|
2084
2127
|
priority: z.enum([
|
|
2085
2128
|
"urgent",
|
|
2086
2129
|
"high",
|
|
@@ -2095,7 +2138,7 @@ const createTicketTools = (ctx) => {
|
|
|
2095
2138
|
]).optional().describe("Ticket type. One of problem, incident, question, task."),
|
|
2096
2139
|
assignee_id: z.number().int().optional().describe("User id of the agent to assign the ticket to."),
|
|
2097
2140
|
group_id: z.number().int().optional().describe("Id of the group to assign the ticket to."),
|
|
2098
|
-
tags: z.array(z.string()).optional().describe("Tags to set on the ticket."),
|
|
2141
|
+
tags: z.array(z.string()).optional().describe("Tags to set on the new ticket. Each tag is a single lowercase token (join multi-word tags with an underscore). Use manage_tags later to add or remove individual tags."),
|
|
2099
2142
|
custom_fields: z.array(z.object({
|
|
2100
2143
|
id: z.number().int(),
|
|
2101
2144
|
value: z.unknown()
|
|
@@ -2127,7 +2170,7 @@ const createTicketTools = (ctx) => {
|
|
|
2127
2170
|
title: "Update Zendesk Ticket",
|
|
2128
2171
|
description: "Update an existing ticket (status, priority, type, assignee, group, subject, tags, custom fields). Only the fields you pass are changed, and the updated ticket is returned. Setting tags here replaces the whole tag set — use manage_tags to add or remove individual tags without overwriting the rest. This tool does not post replies: use add_public_comment or add_private_note for that. Find the ticket id via search_tickets or list_tickets.",
|
|
2129
2172
|
inputSchema: z.object({
|
|
2130
|
-
ticket_id: z.number().int().describe("Ticket ID"),
|
|
2173
|
+
ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket to update. Obtain it from search_tickets or list_tickets."),
|
|
2131
2174
|
status: z.enum([
|
|
2132
2175
|
"new",
|
|
2133
2176
|
"open",
|
|
@@ -2150,7 +2193,7 @@ const createTicketTools = (ctx) => {
|
|
|
2150
2193
|
]).optional().describe("Ticket type. One of problem, incident, question, task."),
|
|
2151
2194
|
assignee_id: z.number().int().optional().describe("User id of the agent to assign the ticket to."),
|
|
2152
2195
|
group_id: z.number().int().optional().describe("Id of the group to assign the ticket to."),
|
|
2153
|
-
subject: z.string().optional().describe("New ticket subject
|
|
2196
|
+
subject: z.string().optional().describe("New subject line for the ticket; replaces the current subject when provided."),
|
|
2154
2197
|
tags: z.array(z.string()).optional().describe("Replaces the full tag set on the ticket. Use manage_tags for incremental add/remove."),
|
|
2155
2198
|
custom_fields: z.array(z.object({
|
|
2156
2199
|
id: z.number().int(),
|
|
@@ -2177,10 +2220,11 @@ const createTicketTools = (ctx) => {
|
|
|
2177
2220
|
namespace: "tickets",
|
|
2178
2221
|
readOnly: false,
|
|
2179
2222
|
title: "Add Private Note",
|
|
2180
|
-
description: "Add an internal note (not visible to requester) to a ticket.",
|
|
2223
|
+
description: "Add an internal note (not visible to requester) to a ticket, optionally with file attachments (uploaded via the Zendesk Uploads API and carried on the note). The note is appended to the ticket thread; use add_public_comment instead when the reply should be visible to the requester.",
|
|
2181
2224
|
inputSchema: z.object({
|
|
2182
|
-
ticket_id: z.number().int().describe("Ticket ID"),
|
|
2183
|
-
body: z.string().min(1).describe("Note
|
|
2225
|
+
ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket to annotate. Obtain it from search_tickets or list_tickets."),
|
|
2226
|
+
body: z.string().min(1).describe("Note text (internal, agent-only). Plain text or HTML; not shown to the requester."),
|
|
2227
|
+
attachments: z.array(attachmentSchema).optional().describe("Files to attach to this note (base64-encoded content).")
|
|
2184
2228
|
}),
|
|
2185
2229
|
annotations: {
|
|
2186
2230
|
readOnlyHint: false,
|
|
@@ -2189,14 +2233,17 @@ const createTicketTools = (ctx) => {
|
|
|
2189
2233
|
openWorldHint: true
|
|
2190
2234
|
},
|
|
2191
2235
|
handler: async (params) => {
|
|
2192
|
-
const { ticket_id, body } = params;
|
|
2193
|
-
|
|
2236
|
+
const { ticket_id, body, attachments } = params;
|
|
2237
|
+
const token = await getToken();
|
|
2238
|
+
const uploads = attachments?.length ? [await uploadAttachments(token, attachments)] : void 0;
|
|
2239
|
+
await zendeskPut(subdomain, token, `/tickets/${ticket_id}`, { ticket: { comment: {
|
|
2194
2240
|
body,
|
|
2195
|
-
public: false
|
|
2241
|
+
public: false,
|
|
2242
|
+
...uploads && { uploads }
|
|
2196
2243
|
} } });
|
|
2197
2244
|
return { content: [{
|
|
2198
2245
|
type: "text",
|
|
2199
|
-
text: `Private note added to ticket #${ticket_id}.`
|
|
2246
|
+
text: `Private note added to ticket #${ticket_id}${formatAttachmentSuffix(attachments?.length)}.`
|
|
2200
2247
|
}] };
|
|
2201
2248
|
}
|
|
2202
2249
|
},
|
|
@@ -2205,10 +2252,11 @@ const createTicketTools = (ctx) => {
|
|
|
2205
2252
|
namespace: "tickets",
|
|
2206
2253
|
readOnly: false,
|
|
2207
2254
|
title: "Add Public Comment",
|
|
2208
|
-
description: "Add a public comment (visible to requester) to a ticket.",
|
|
2255
|
+
description: "Add a public comment (visible to requester) to a ticket, optionally with file attachments (uploaded via the Zendesk Uploads API and carried on the comment). The comment is appended to the ticket thread and emails the requester; use add_private_note instead for an internal, agent-only note.",
|
|
2209
2256
|
inputSchema: z.object({
|
|
2210
|
-
ticket_id: z.number().int().describe("Ticket ID"),
|
|
2211
|
-
body: z.string().min(1).describe("Comment
|
|
2257
|
+
ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket to reply on. Obtain it from search_tickets or list_tickets."),
|
|
2258
|
+
body: z.string().min(1).describe("Comment text sent to the requester. Plain text or HTML; visible in the ticket."),
|
|
2259
|
+
attachments: z.array(attachmentSchema).optional().describe("Files to attach to this comment (base64-encoded content).")
|
|
2212
2260
|
}),
|
|
2213
2261
|
annotations: {
|
|
2214
2262
|
readOnlyHint: false,
|
|
@@ -2217,14 +2265,17 @@ const createTicketTools = (ctx) => {
|
|
|
2217
2265
|
openWorldHint: true
|
|
2218
2266
|
},
|
|
2219
2267
|
handler: async (params) => {
|
|
2220
|
-
const { ticket_id, body } = params;
|
|
2221
|
-
|
|
2268
|
+
const { ticket_id, body, attachments } = params;
|
|
2269
|
+
const token = await getToken();
|
|
2270
|
+
const uploads = attachments?.length ? [await uploadAttachments(token, attachments)] : void 0;
|
|
2271
|
+
await zendeskPut(subdomain, token, `/tickets/${ticket_id}`, { ticket: { comment: {
|
|
2222
2272
|
body,
|
|
2223
|
-
public: true
|
|
2273
|
+
public: true,
|
|
2274
|
+
...uploads && { uploads }
|
|
2224
2275
|
} } });
|
|
2225
2276
|
return { content: [{
|
|
2226
2277
|
type: "text",
|
|
2227
|
-
text: `Public comment added to ticket #${ticket_id}.`
|
|
2278
|
+
text: `Public comment added to ticket #${ticket_id}${formatAttachmentSuffix(attachments?.length)}.`
|
|
2228
2279
|
}] };
|
|
2229
2280
|
}
|
|
2230
2281
|
},
|
|
@@ -2233,7 +2284,7 @@ const createTicketTools = (ctx) => {
|
|
|
2233
2284
|
namespace: "tickets",
|
|
2234
2285
|
readOnly: true,
|
|
2235
2286
|
title: "List Zendesk Tickets",
|
|
2236
|
-
description: "List tickets with cursor-based pagination,
|
|
2287
|
+
description: "List tickets with cursor-based pagination, in Zendesk's default order (ascending ticket id), not by recency. 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. To find tickets by recency or any other criterion, use search_tickets with a query.",
|
|
2237
2288
|
inputSchema: z.object({
|
|
2238
2289
|
page_size: z.number().int().min(1).max(100).default(100).describe("Tickets per page (1-100, default 100)."),
|
|
2239
2290
|
cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page.")
|
|
@@ -2259,8 +2310,8 @@ const createTicketTools = (ctx) => {
|
|
|
2259
2310
|
namespace: "tickets",
|
|
2260
2311
|
readOnly: true,
|
|
2261
2312
|
title: "Get Linked Incidents",
|
|
2262
|
-
description: "Get all incident tickets linked to a problem ticket.",
|
|
2263
|
-
inputSchema: z.object({ problem_id: z.number().int().describe("Problem ticket ID") }),
|
|
2313
|
+
description: "Get all incident tickets linked to a problem ticket. Returns the list of incidents that reference the given problem (Zendesk problem/incident relationship); useful to gauge a problem's blast radius before resolving it.",
|
|
2314
|
+
inputSchema: z.object({ problem_id: z.number().int().describe("Problem ticket ID — the numeric id of the ticket of type \"problem\" whose linked incidents to list. Obtain it from search_tickets or list_tickets.") }),
|
|
2264
2315
|
annotations: {
|
|
2265
2316
|
readOnlyHint: true,
|
|
2266
2317
|
destructiveHint: false,
|
|
@@ -2281,11 +2332,11 @@ const createTicketTools = (ctx) => {
|
|
|
2281
2332
|
namespace: "tickets",
|
|
2282
2333
|
readOnly: false,
|
|
2283
2334
|
title: "Manage Ticket Tags",
|
|
2284
|
-
description: "Add or remove tags on a ticket.",
|
|
2335
|
+
description: "Add or remove tags on a ticket. Performs an incremental read-modify-write: it fetches the ticket's current tags, adds those in `add` and deletes those in `remove`, then saves the merged set — tags you don't list are left untouched and duplicates are collapsed. Adding a tag already present, or removing one that is absent, is a no-op (idempotent). Returns the ticket's full tag set after the update. Use this for incremental tag edits; to overwrite the entire tag set at once, or to change tags alongside other fields, use update_ticket instead. Find the ticket id via search_tickets or list_tickets.",
|
|
2285
2336
|
inputSchema: z.object({
|
|
2286
|
-
ticket_id: z.number().int().describe("Ticket ID"),
|
|
2287
|
-
add: z.array(z.string()).optional().describe("Tags to add"),
|
|
2288
|
-
remove: z.array(z.string()).optional().describe("Tags to remove")
|
|
2337
|
+
ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket whose tags to modify. Obtain it from search_tickets or list_tickets."),
|
|
2338
|
+
add: z.array(z.string()).optional().describe("Tags to add. Zendesk tags are single tokens: a value containing spaces is stored as separate tags rather than one tag, so join multi-word tags yourself with an underscore or dash (e.g. \"urgent_request\"). Adding a tag already on the ticket is a no-op. Omit to only remove."),
|
|
2339
|
+
remove: z.array(z.string()).optional().describe("Tags to remove. Removing a tag that is not present is a no-op; tags not listed here stay in place. Omit to only add.")
|
|
2289
2340
|
}),
|
|
2290
2341
|
annotations: {
|
|
2291
2342
|
readOnlyHint: false,
|
|
@@ -2318,8 +2369,8 @@ const createTicketTools = (ctx) => {
|
|
|
2318
2369
|
title: "List SLA Policies",
|
|
2319
2370
|
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
2371
|
inputSchema: z.object({
|
|
2321
|
-
per_page: z.number().int().min(1).max(100).default(100).describe(
|
|
2322
|
-
page: z.number().int().min(1).default(1).describe(
|
|
2372
|
+
per_page: z.number().int().min(1).max(100).default(100).describe(PER_PAGE_DESC),
|
|
2373
|
+
page: z.number().int().min(1).default(1).describe(PAGE_DESC)
|
|
2323
2374
|
}),
|
|
2324
2375
|
annotations: {
|
|
2325
2376
|
readOnlyHint: true,
|
|
@@ -2383,9 +2434,9 @@ const createUserTools = (ctx) => {
|
|
|
2383
2434
|
title: "Search Zendesk Users",
|
|
2384
2435
|
description: "Search for users by name, email, or other criteria using Zendesk search query syntax. Returns total count.",
|
|
2385
2436
|
inputSchema: z.object({
|
|
2386
|
-
query: z.string().min(1).describe("
|
|
2387
|
-
per_page: z.number().int().min(1).max(100).default(100).describe(
|
|
2388
|
-
page: z.number().int().min(1).default(1).describe(
|
|
2437
|
+
query: z.string().min(1).describe("Zendesk user search query — free text matched against name and email, and/or field filters like \"email:jane@acme.com\", \"role:agent\", \"organization_id:123\". A \"type:user\" scope is added automatically."),
|
|
2438
|
+
per_page: z.number().int().min(1).max(100).default(100).describe(PER_PAGE_DESC),
|
|
2439
|
+
page: z.number().int().min(1).default(1).describe(PAGE_DESC)
|
|
2389
2440
|
}),
|
|
2390
2441
|
annotations: {
|
|
2391
2442
|
readOnlyHint: true,
|
|
@@ -2410,8 +2461,8 @@ const createUserTools = (ctx) => {
|
|
|
2410
2461
|
namespace: "users",
|
|
2411
2462
|
readOnly: true,
|
|
2412
2463
|
title: "Get Zendesk User",
|
|
2413
|
-
description: "Retrieve a user by
|
|
2414
|
-
inputSchema: z.object({ user_id: z.number().int().describe("User ID") }),
|
|
2464
|
+
description: "Retrieve a single user by their numeric id. Returns the full user record (name, email, role, organization, tags). Use search_users when you only have a name or email, or get_current_user for the authenticated identity.",
|
|
2465
|
+
inputSchema: z.object({ user_id: z.number().int().describe("User ID — the numeric id of the Zendesk user to fetch. Obtain it from search_users, or from the requester/assignee fields of a ticket.") }),
|
|
2415
2466
|
annotations: {
|
|
2416
2467
|
readOnlyHint: true,
|
|
2417
2468
|
destructiveHint: false,
|
|
@@ -2432,8 +2483,8 @@ const createUserTools = (ctx) => {
|
|
|
2432
2483
|
namespace: "users",
|
|
2433
2484
|
readOnly: true,
|
|
2434
2485
|
title: "Get Zendesk Organization",
|
|
2435
|
-
description: "Retrieve
|
|
2436
|
-
inputSchema: z.object({ organization_id: z.number().int().describe("Organization ID") }),
|
|
2486
|
+
description: "Retrieve a single organization by its numeric id. Returns full details (name, tags, domains, notes) — more than the name/id that search or list_organizations surface. Use list_organizations to browse or search for a name-based lookup.",
|
|
2487
|
+
inputSchema: z.object({ organization_id: z.number().int().describe("Organization ID — the numeric id of the Zendesk organization to fetch. Obtain it from list_organizations, search, or a user record.") }),
|
|
2437
2488
|
annotations: {
|
|
2438
2489
|
readOnlyHint: true,
|
|
2439
2490
|
destructiveHint: false,
|