@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 CHANGED
@@ -8,10 +8,12 @@
8
8
  [![Renovate enabled](https://img.shields.io/badge/renovate-enabled-brightgreen?logo=renovatebot&logoColor=white)](https://renovatebot.com)
9
9
  [![semantic-release](https://img.shields.io/badge/semantic--release-e10079?logo=semantic-release&logoColor=white)](https://github.com/semantic-release/semantic-release)
10
10
 
11
- **Bring Zendesk Support & the Help Center into your AI assistant.** A
12
- [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that lets
13
- your assistant search articles, answer questions, and create, track and update
14
- tickets in plain language **without switching apps**.
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("Search query"),
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("Results per page"),
1214
- page: z.number().int().min(1).default(1).describe("Page number")
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("Article ID"),
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("Sort field"),
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("Article ID") }),
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 (HTML)"),
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 IDs"),
1519
- user_segment_id: z.number().int().optional().describe("User segment ID for visibility"),
1520
- author_id: z.number().int().optional().describe("Author user ID"),
1521
- permission_group_id: z.number().int().optional().describe("Permission group ID"),
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((await helpCenterGet(subdomain, await getToken(), `/articles/${article_id}/attachments`)).article_attachments ?? [], formatAttachment)
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("Article ID"),
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("Article ID"),
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("Article ID"),
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("Article ID"),
1762
- source_locale: z.string().describe("Source (reference) locale"),
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("Article ID"),
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("Results per page (max 100)"),
1866
- page: z.number().int().min(1).default(1).describe("Page number (1-based)")
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`, buildCursorParams(100, cursor));
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("Include ticket comments")
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 type:incident\". Returns total count, so queue triage like \"breaching today\" works without a per-ticket fetch.",
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 string"),
2052
- per_page: z.number().int().min(1).max(100).default(100).describe("Results per page"),
2053
- page: z.number().int().min(1).default(1).describe("Page number")
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 line."),
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 content")
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
- await zendeskPut(subdomain, await getToken(), `/tickets/${ticket_id}`, { ticket: { comment: {
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 content")
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
- await zendeskPut(subdomain, await getToken(), `/tickets/${ticket_id}`, { ticket: { comment: {
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, 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.",
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("Results per page"),
2322
- page: z.number().int().min(1).default(1).describe("Page number")
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("Search query"),
2387
- per_page: z.number().int().min(1).max(100).default(100).describe("Results per page"),
2388
- page: z.number().int().min(1).default(1).describe("Page number")
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 ID.",
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 an organization by ID.",
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,