@fruggr/zendesk-mcp-server 3.5.3 → 3.7.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/dist/index.js CHANGED
@@ -7630,6 +7630,14 @@ const isStartupError = (err) => err instanceof Error && err.name === "StartupErr
7630
7630
  /** The errno code of a failed filesystem call, for the message. */
7631
7631
  const errnoCode = (err) => err?.code ?? "unknown error";
7632
7632
  //#endregion
7633
+ //#region src/utils/compare.ts
7634
+ /**
7635
+ * Orders strings by UTF-16 code unit, which is what a bare `.sort()` does.
7636
+ * Not `localeCompare`: an order that feeds a hash or a stored key must not move
7637
+ * with the runtime's locale data.
7638
+ */
7639
+ const compareCodeUnits = (a, b) => Number(a > b) - Number(a < b);
7640
+ //#endregion
7633
7641
  //#region src/utils/validation.ts
7634
7642
  const formatPath = (path) => path.map(String).join(".");
7635
7643
  const formatIssue$1 = (issue) => issue.path.length > 0 ? `${formatPath(issue.path)}: ${issue.message}` : issue.message;
@@ -7651,7 +7659,7 @@ const keysAt = (node, path) => {
7651
7659
  const child = typeof head === "number" ? def.element : def.shape?.[String(head)];
7652
7660
  return child && keysAt(child, rest);
7653
7661
  };
7654
- const keyList = (keys) => [...keys].sort().join(", ") || "(none)";
7662
+ const keyList = (keys) => [...keys].sort(compareCodeUnits).join(", ") || "(none)";
7655
7663
  /** One sentence per object holding unknown keys, the top level first. */
7656
7664
  const unknownKeysMessage = (schema, issues, topLevelKeys) => [...issues].sort((a, b) => a.path.length - b.path.length).map(({ path, keys }) => {
7657
7665
  if (path.length === 0) return `Unknown parameter(s): ${keys.join(", ")}. Valid parameters: ${topLevelKeys}.`;
@@ -30076,7 +30084,7 @@ const createHelpCenterTools = (env) => {
30076
30084
  namespace: "help_center",
30077
30085
  readOnly: true,
30078
30086
  title: "List Permission Groups",
30079
- description: "List all Guide permission groups. Use this to find the permission_group_id required when creating articles.",
30087
+ description: "List all Guide permission groups, which control who can edit and publish an article, with each group's id, name and whether it is built-in. Use this to find the permission_group_id required when creating articles with create_article, or to change it with update_article; to control who can view an article instead, use list_user_segments. Zendesk restricts this list to Guide admins and Help Center managers: without that role the call fails, and you can reuse the permission_group_id of an existing article read with get_article.",
30080
30088
  inputSchema: object({}),
30081
30089
  annotations: {
30082
30090
  readOnlyHint: true,
@@ -30141,7 +30149,7 @@ const createHelpCenterTools = (env) => {
30141
30149
  namespace: "help_center",
30142
30150
  readOnly: false,
30143
30151
  title: "Update Help Center Article",
30144
- description: "Update article metadata only (draft, promoted, labels as label_names, content tags as content_tag_ids, visibility as permission_group_id / user_segment_id, section_id, sort position, etc.) and return the updated article. Does NOT update content (title, body) — use update_article_translation for that.",
30152
+ description: "Update article metadata only (draft, promoted, labels as label_names, content tags as content_tag_ids, visibility as permission_group_id / user_segment_id, section_id, sort position, author, etc.) and return the updated article; fields you omit keep their value. Does NOT update content (title, body) — use update_article_translation for that, or update_article_section to edit one section. To put an article first or last in its section, prefer reorder_article: a plain position write is ambiguous while articles share position 0. To remove an article from the Help Center, use archive_article (a Guide admin can restore it); draft: true only unpublishes it.",
30145
30153
  inputSchema: object({
30146
30154
  article_id: number$1().int().describe("Article ID — the numeric id of the article to update. Obtain it from list_articles or search_articles."),
30147
30155
  draft: boolean().optional().describe("Set true to unpublish the article (revert to draft) or false to publish it."),
@@ -30328,7 +30336,7 @@ const createHelpCenterTools = (env) => {
30328
30336
  namespace: "help_center",
30329
30337
  readOnly: true,
30330
30338
  title: "List Article Labels",
30331
- description: "List all article labels. Labels improve Help Center search ranking and are not visible to end users. Attach them to an article through the label_names parameter of create_article or update_article.",
30339
+ description: "List all article labels, with each label's id and name. Labels improve Help Center search ranking and are not visible to end users. Attach them to an article through the label_names parameter of create_article or update_article, reusing an existing label rather than coining a near-duplicate. Labels are not content tags: for the end-user-visible tags readers browse by, use list_content_tags.",
30332
30340
  inputSchema: object({ ...brandIdPart }),
30333
30341
  annotations: {
30334
30342
  readOnlyHint: true,
@@ -30352,7 +30360,7 @@ const createHelpCenterTools = (env) => {
30352
30360
  namespace: "help_center",
30353
30361
  readOnly: true,
30354
30362
  title: "List User Segments",
30355
- description: "List all user segments. User segments control article visibility (who can view). Use the ID when creating or updating articles.",
30363
+ description: "List all Help Center user segments, which control article visibility (who can view an article), with each segment's id, name, user type and whether it is built-in. Use the id as user_segment_id when creating or updating articles with create_article or update_article; to control who can edit an article instead, use list_permission_groups. Zendesk restricts this list to Guide admins and Help Center managers: without that role the call fails, and you can reuse the user_segment_id of an existing article from get_article, or omit it on create_article to make the article visible to everyone.",
30356
30364
  inputSchema: object({}),
30357
30365
  annotations: {
30358
30366
  readOnlyHint: true,
@@ -30832,10 +30840,11 @@ const fetchAllPages = async ({ subdomain, token, tool, path, extract, params = {
30832
30840
  const items = [];
30833
30841
  let page = 1;
30834
30842
  while (true) {
30835
- const response = await withForbiddenGuidance(tool, `GET ${path}`, () => zendeskGet(subdomain, token, path, {
30843
+ const query = {
30836
30844
  ...params,
30837
30845
  ...buildOffsetParams(100, page)
30838
- }), forbiddenHint);
30846
+ };
30847
+ const response = await withForbiddenGuidance(tool, `GET ${path}`, () => zendeskGet(subdomain, token, path, query), forbiddenHint);
30839
30848
  items.push(...extract(response) ?? []);
30840
30849
  onPage?.(response);
30841
30850
  if (!response.next_page) return items;
@@ -31097,8 +31106,8 @@ const createRequestTools = (env) => {
31097
31106
  body: string().min(1).describe("The request itself: what happened, what was expected, and any steps to reproduce. Becomes the first public comment on the ticket."),
31098
31107
  form_id: number$1().int().optional().describe("Form id chosen from list_request_forms, deciding which questions apply. Omit to use the account default form."),
31099
31108
  custom_fields: array(object({
31100
- id: number$1().int(),
31101
- value: unknown()
31109
+ id: number$1().int().describe("Numeric id of a question on the chosen form, from get_request_form."),
31110
+ value: unknown().describe("The answer, typed after the question: the option tag (a string, never a number) for a dropdown, an array of option tags for a multiselect, true or false for a checkbox, text otherwise.")
31102
31111
  }).strict()).optional().describe("Answers to the form's own questions, as { id, value } pairs. Take both the ids and the accepted values from get_request_form; an id this form does not carry, or a value a dropdown does not offer, is refused here because Zendesk would drop it without an error. Option values are the tag strings, never numbers."),
31103
31112
  attachments: attachmentsParam("Files to attach to the request, such as a screenshot or a log, with their content base64-encoded.")
31104
31113
  }),
@@ -31273,70 +31282,6 @@ const createRequestTools = (env) => {
31273
31282
  ];
31274
31283
  };
31275
31284
  //#endregion
31276
- //#region src/tools/search.ts
31277
- const formatSearchResult = (result) => {
31278
- const lines = [`## [${result["result_type"]}] #${result["id"]}`];
31279
- if (result["subject"]) lines.push(`**Subject**: ${result["subject"]}`);
31280
- if (result["name"]) lines.push(`**Name**: ${result["name"]}`);
31281
- if (result["title"]) lines.push(`**Title**: ${result["title"]}`);
31282
- if (result["email"]) lines.push(`**Email**: ${result["email"]}`);
31283
- if (result["status"]) lines.push(`**Status**: ${result["status"]}`);
31284
- if (result["description"]) lines.push(descriptionExcerpt(String(result["description"])));
31285
- return lines.join("\n");
31286
- };
31287
- const createSearchTools = (env) => {
31288
- const { subdomain } = env;
31289
- return [{
31290
- name: "search",
31291
- namespace: "tickets",
31292
- readOnly: true,
31293
- title: "Zendesk Unified Search",
31294
- description: "Search across tickets, users, and organizations. Supports filters like \"type:ticket status:open\", \"type:user role:agent\". Returns total count and paginated results (25 per page by default, up to 100); a ticket description is cut to a 200-character excerpt, which get_ticket returns in full. Organization results include name and ID only — use get_organization for full details (tags, domains, details).",
31295
- inputSchema: object({
31296
- query: 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."),
31297
- per_page: number$1().int().min(1).max(100).default(25).describe(PER_PAGE_DESC),
31298
- page: number$1().int().min(1).default(1).describe(PAGE_DESC)
31299
- }),
31300
- annotations: {
31301
- readOnlyHint: true,
31302
- destructiveHint: false,
31303
- idempotentHint: true,
31304
- openWorldHint: true
31305
- },
31306
- handler: async (params, { caller }) => {
31307
- const { query, per_page, page } = params;
31308
- const token = await caller.getToken();
31309
- const response = await zendeskGet(subdomain, token, "/search", {
31310
- query,
31311
- ...buildOffsetParams(per_page, page)
31312
- });
31313
- const results = response.results ?? [];
31314
- const meta = extractSearchPaginationMeta(response, per_page, page);
31315
- const header = (m) => `Total: ${m.count} | Page ${page} (${results.length} results)${m.has_more ? ` | Next page: ${m.next_page}` : ""}`;
31316
- return { content: [{
31317
- type: "text",
31318
- text: formatFittingList(results, formatSearchResult, meta, offsetResume(page, per_page), header)
31319
- }] };
31320
- }
31321
- }];
31322
- };
31323
- //#endregion
31324
- //#region src/utils/elicitation.ts
31325
- /**
31326
- * Whether a client can answer a form elicitation. A bare `elicitation: {}`
31327
- * predates the modes and counts as form support (spec rule the SDK follows).
31328
- */
31329
- const supportsFormElicitation = (capabilities) => {
31330
- const elicitation = capabilities?.elicitation;
31331
- if (!elicitation) return false;
31332
- return Object.keys(elicitation).length === 0 || "form" in elicitation;
31333
- };
31334
- const inputRequiredError = (result) => Object.assign(/* @__PURE__ */ new Error("The tool needs input from the user before it can answer."), {
31335
- name: "InputRequiredError",
31336
- inputRequired: result
31337
- });
31338
- const isInputRequiredError = (err) => err instanceof Error && err.name === "InputRequiredError" && "inputRequired" in err;
31339
- //#endregion
31340
31285
  //#region src/utils/sla-urgency.ts
31341
31286
  /** Most urgent first: the order alerts are grouped and ranked in. */
31342
31287
  const URGENCY_LEVELS = [
@@ -31543,6 +31488,95 @@ const fetchScope = async (scope, get, userId) => {
31543
31488
  });
31544
31489
  return mergeSnapshots(snapshots);
31545
31490
  };
31491
+ //#endregion
31492
+ //#region src/utils/group-me.ts
31493
+ const GROUP_ME = /(^|\s)(-?)group:(?:me|"me")(?=\s|$)/gi;
31494
+ const NOT_A_VALUE = "group:me is not a Zendesk search value: \"me\" works only for user keywords (assignee:, requester:, submitter:), and Zendesk would silently match nothing.";
31495
+ /**
31496
+ * Reads `group:me` in a search query as the caller's group. With exactly one
31497
+ * group it becomes `group:<id>`, with a note to show; with none or several it
31498
+ * throws, naming the groups, since one offset-paginated search cannot cover
31499
+ * several groups. Without `group:me`, nothing is looked up.
31500
+ */
31501
+ const resolveGroupMe = async (query, get, userId) => {
31502
+ if (query.match(GROUP_ME) === null) return { query };
31503
+ const groupIds = await readGroupIds(get, await userId());
31504
+ const [only, ...others] = groupIds;
31505
+ if (only === void 0) throw new Error(`${NOT_A_VALUE} You belong to no group, so there is no group queue to search: drop group:me, or name a group with group:<id>.`);
31506
+ if (others.length === 0) return {
31507
+ query: query.replace(GROUP_ME, (_, lead, negation) => `${lead}${negation}group:${only}`),
31508
+ note: `Note: group:me was read as group:${only}, your only group.`
31509
+ };
31510
+ const advice = [...query.matchAll(GROUP_ME)].every(([, , negation]) => negation === "-") ? `exclude a group with -group:<id> (e.g. -group:${only})` : `search one with group:<id> (e.g. group:${only}), one call per group`;
31511
+ throw new Error(`${NOT_A_VALUE} You belong to groups ${groupIds.join(", ")}: ${advice}.`);
31512
+ };
31513
+ /** `resolveGroupMe` for a tool call: the caller comes from the SLA service when wired, else `/users/me`. */
31514
+ const resolveCallerGroupMe = (query, subdomain, token, sla) => resolveGroupMe(query, (path, params) => zendeskGet(subdomain, token, path, params), sla?.userId ?? (() => zendeskGet(subdomain, token, "/users/me").then(({ user }) => user.id)));
31515
+ //#endregion
31516
+ //#region src/tools/search.ts
31517
+ const formatSearchResult = (result) => {
31518
+ const lines = [`## [${result["result_type"]}] #${result["id"]}`];
31519
+ if (result["subject"]) lines.push(`**Subject**: ${result["subject"]}`);
31520
+ if (result["name"]) lines.push(`**Name**: ${result["name"]}`);
31521
+ if (result["title"]) lines.push(`**Title**: ${result["title"]}`);
31522
+ if (result["email"]) lines.push(`**Email**: ${result["email"]}`);
31523
+ if (result["status"]) lines.push(`**Status**: ${result["status"]}`);
31524
+ if (result["description"]) lines.push(descriptionExcerpt(String(result["description"])));
31525
+ return lines.join("\n");
31526
+ };
31527
+ const createSearchTools = (env) => {
31528
+ const { subdomain } = env;
31529
+ return [{
31530
+ name: "search",
31531
+ namespace: "tickets",
31532
+ readOnly: true,
31533
+ title: "Zendesk Unified Search",
31534
+ description: "Search across tickets, users, and organizations. Supports filters like \"type:ticket status:open\", \"type:user role:agent\". Returns total count and paginated results (25 per page by default, up to 100); a ticket description is cut to a 200-character excerpt, which get_ticket returns in full. Organization results include name and ID only — use get_organization for full details (tags, domains, details).",
31535
+ inputSchema: object({
31536
+ query: 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. \"me\" is a value of the user keywords only (assignee:, requester:, submitter:); group:me is read as your group when you belong to exactly one, else the call fails naming your groups, so use group:<id>."),
31537
+ per_page: number$1().int().min(1).max(100).default(25).describe(PER_PAGE_DESC),
31538
+ page: number$1().int().min(1).default(1).describe(PAGE_DESC)
31539
+ }),
31540
+ annotations: {
31541
+ readOnlyHint: true,
31542
+ destructiveHint: false,
31543
+ idempotentHint: true,
31544
+ openWorldHint: true
31545
+ },
31546
+ handler: async (params, { caller }) => {
31547
+ const { query, per_page, page } = params;
31548
+ const token = await caller.getToken();
31549
+ const resolved = await resolveCallerGroupMe(query, subdomain, token, caller.sla);
31550
+ const response = await zendeskGet(subdomain, token, "/search", {
31551
+ query: resolved.query,
31552
+ ...buildOffsetParams(per_page, page)
31553
+ });
31554
+ const results = response.results ?? [];
31555
+ const meta = extractSearchPaginationMeta(response, per_page, page);
31556
+ const header = (m) => [resolved.note, `Total: ${m.count} | Page ${page} (${results.length} results)${m.has_more ? ` | Next page: ${m.next_page}` : ""}`].filter(Boolean).join("\n");
31557
+ return { content: [{
31558
+ type: "text",
31559
+ text: formatFittingList(results, formatSearchResult, meta, offsetResume(page, per_page), header)
31560
+ }] };
31561
+ }
31562
+ }];
31563
+ };
31564
+ //#endregion
31565
+ //#region src/utils/elicitation.ts
31566
+ /**
31567
+ * Whether a client can answer a form elicitation. A bare `elicitation: {}`
31568
+ * predates the modes and counts as form support (spec rule the SDK follows).
31569
+ */
31570
+ const supportsFormElicitation = (capabilities) => {
31571
+ const elicitation = capabilities?.elicitation;
31572
+ if (!elicitation) return false;
31573
+ return Object.keys(elicitation).length === 0 || "form" in elicitation;
31574
+ };
31575
+ const inputRequiredError = (result) => Object.assign(/* @__PURE__ */ new Error("The tool needs input from the user before it can answer."), {
31576
+ name: "InputRequiredError",
31577
+ inputRequired: result
31578
+ });
31579
+ const isInputRequiredError = (err) => err instanceof Error && err.name === "InputRequiredError" && "inputRequired" in err;
31546
31580
  /** Where the thresholds in force come from, as the report states it. */
31547
31581
  const thresholdsSource = (sla, prefs, overridden) => {
31548
31582
  if (overridden) return "overridden for this call";
@@ -31761,6 +31795,7 @@ const fetchAllTicketComments = async (subdomain, token, ticketId) => {
31761
31795
  const all = [];
31762
31796
  let cursor;
31763
31797
  let pages = 0;
31798
+ let truncated = false;
31764
31799
  while (pages < MAX_COMMENT_PAGES) {
31765
31800
  const response = await zendeskGet(subdomain, token, `/tickets/${ticketId}/comments`, {
31766
31801
  ...buildCursorParams(100, cursor),
@@ -31770,9 +31805,14 @@ const fetchAllTicketComments = async (subdomain, token, ticketId) => {
31770
31805
  pages += 1;
31771
31806
  if (!response.meta?.has_more || !response.meta?.after_cursor) break;
31772
31807
  cursor = response.meta.after_cursor;
31808
+ truncated = pages === MAX_COMMENT_PAGES;
31773
31809
  }
31774
- return all;
31810
+ return {
31811
+ comments: all,
31812
+ truncated
31813
+ };
31775
31814
  };
31815
+ const COMMENT_CAP_NOTICE = `Comment scan stopped at its page cap (${MAX_COMMENT_PAGES} pages, COMMENT_MAX_PAGES): attachments on any later comments are not listed. Pass attachment_ids taken from list_ticket_comments to fetch them.`;
31776
31816
  const commentPageMeta = (response, itemCount) => extractPaginationMeta({
31777
31817
  ...response.meta && { meta: response.meta },
31778
31818
  ...response.count !== void 0 && { count: response.count }
@@ -32074,6 +32114,10 @@ const createTicketTools = (env) => {
32074
32114
  const ids = array(number$1().int().positive());
32075
32115
  return (max === void 0 ? ids : ids.max(max)).optional().describe(description);
32076
32116
  };
32117
+ const customFieldEntry = object({
32118
+ id: number$1().int().describe("Numeric id of the ticket field to set, as listed by list_ticket_fields."),
32119
+ value: unknown().describe("Value typed after the field: the option tag (a string) for a dropdown, an array of option tags for a multiselect, true or false for a checkbox, text or a number for a text or numeric field. For any other type (a date, a lookup), follow the type list_ticket_fields reports; it also lists each dropdown's accepted tags.")
32120
+ }).strict();
32077
32121
  return [
32078
32122
  {
32079
32123
  name: "get_ticket",
@@ -32211,10 +32255,10 @@ const createTicketTools = (env) => {
32211
32255
  namespace: "tickets",
32212
32256
  readOnly: true,
32213
32257
  title: "Get Zendesk Ticket Attachments",
32214
- description: "Retrieve ticket attachments. Images are embedded inline; other files are listed as text references.",
32258
+ description: "Download the files attached to a ticket's comments so you can look at them: images come back embedded inline, other files as text references (file name, id, content type, size, download URL). Use it to see an attached image, or a file's name, size and download URL (only images come back with their content); to learn which files a comment carries, list_ticket_comments already shows their ids. Pass those ids as attachment_ids to fetch only them, or omit it to collect the attachments of the ticket's comments, read up to a page cap (10 pages of 100 comments by default); past it the response says later comments were not scanned, and attachment_ids fetches their files. An image over the per-image size limit or past the embedded-image cap comes back as a reference saying why, and once the response size budget is spent the remaining files are counted as omitted.",
32215
32259
  inputSchema: object({
32216
32260
  ticket_id: number$1().int().describe("Ticket ID — the numeric id of the ticket whose attachments to fetch. Obtain it from search_tickets or list_tickets."),
32217
- attachment_ids: array(number$1().int()).optional().describe("Attachment IDs to fetch directly (e.g. extracted from a previous list_ticket_comments or get_ticket(include_comments=true) call). When provided, skips the comments fetch entirely. When omitted, all attachments of the ticket are returned.")
32261
+ attachment_ids: array(number$1().int()).optional().describe("Attachment IDs to fetch directly (e.g. extracted from a previous list_ticket_comments or get_ticket(include_comments=true) call). When provided, skips the comments fetch entirely. When omitted, the attachments of the ticket's comments are returned, read up to a page cap: on a very long thread the scan stops there, and the response says so.")
32218
32262
  }),
32219
32263
  annotations: {
32220
32264
  readOnlyHint: true,
@@ -32225,16 +32269,31 @@ const createTicketTools = (env) => {
32225
32269
  handler: async (params, { caller }) => {
32226
32270
  const { ticket_id, attachment_ids } = params;
32227
32271
  const token = await caller.getToken();
32228
- const attachments = attachment_ids && attachment_ids.length > 0 ? await fetchAttachmentsByIds(subdomain, token, attachment_ids) : (await fetchAllTicketComments(subdomain, token, ticket_id)).flatMap((c) => c.attachments ?? []);
32272
+ const { attachments, truncated } = attachment_ids && attachment_ids.length > 0 ? {
32273
+ attachments: await fetchAttachmentsByIds(subdomain, token, attachment_ids),
32274
+ truncated: false
32275
+ } : await fetchAllTicketComments(subdomain, token, ticket_id).then((walk) => ({
32276
+ attachments: walk.comments.flatMap((c) => c.attachments ?? []),
32277
+ truncated: walk.truncated
32278
+ }));
32229
32279
  if (attachments.length === 0) return { content: [{
32230
32280
  type: "text",
32231
- text: `No attachments found on ticket #${ticket_id}.`
32281
+ text: truncated ? `No attachments found on the first ${MAX_COMMENT_PAGES} pages of comments of ticket #${ticket_id}. ${COMMENT_CAP_NOTICE}` : `No attachments found on ticket #${ticket_id}.`
32232
32282
  }] };
32233
32283
  const header = {
32234
32284
  type: "text",
32235
32285
  text: `# Attachments for ticket #${ticket_id} (${attachments.length} total)`
32236
32286
  };
32237
- return { content: [header, ...await collectAttachmentBlocks(subdomain, token, attachments, blockCost(header) + 1)] };
32287
+ const notice = truncated ? [{
32288
+ type: "text",
32289
+ text: COMMENT_CAP_NOTICE
32290
+ }] : [];
32291
+ const blocks = await collectAttachmentBlocks(subdomain, token, attachments, [header, ...notice].reduce((total, block) => total + blockCost(block), 1));
32292
+ return { content: [
32293
+ header,
32294
+ ...notice,
32295
+ ...blocks
32296
+ ] };
32238
32297
  }
32239
32298
  },
32240
32299
  {
@@ -32244,7 +32303,7 @@ const createTicketTools = (env) => {
32244
32303
  title: "Search Zendesk Tickets",
32245
32304
  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. When the operator turned SLA push on, each SLA block also carries an Urgency line (BREACHED, CRITICAL or WARNING) against the caller's alert thresholds. ${EXCERPT_NOTE}`,
32246
32305
  inputSchema: object({
32247
- query: 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)."),
32306
+ query: 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). \"me\" is a value of the user keywords only (assignee:, requester:, submitter:); group:me is read as your group when you belong to exactly one, else the call fails naming your groups, so use group:<id>."),
32248
32307
  per_page: number$1().int().min(1).max(100).default(25).describe(PER_PAGE_DESC),
32249
32308
  page: number$1().int().min(1).default(1).describe(PAGE_DESC)
32250
32309
  }),
@@ -32257,15 +32316,22 @@ const createTicketTools = (env) => {
32257
32316
  handler: async (params, { caller }) => {
32258
32317
  const { query, per_page, page } = params;
32259
32318
  const token = await caller.getToken();
32260
- const [response, urgency] = await Promise.all([zendeskGet(subdomain, token, "/search", {
32261
- query: `type:ticket ${query}`,
32262
- include: "tickets(slas)",
32263
- ...buildOffsetParams(per_page, page)
32264
- }), urgencyRenderer(caller.sla)]);
32319
+ const search = async () => {
32320
+ const resolved = await resolveCallerGroupMe(query, subdomain, token, caller.sla);
32321
+ return {
32322
+ resolved,
32323
+ response: await zendeskGet(subdomain, token, "/search", {
32324
+ query: `type:ticket ${resolved.query}`,
32325
+ include: "tickets(slas)",
32326
+ ...buildOffsetParams(per_page, page)
32327
+ })
32328
+ };
32329
+ };
32330
+ const [{ resolved, response }, urgency] = await Promise.all([search(), urgencyRenderer(caller.sla)]);
32265
32331
  const formatTicketWithSla = (ticket) => formatTicketSummary(ticket) + formatSlaBlock(ticket.slas, urgency(ticket.slas));
32266
32332
  return { content: [{
32267
32333
  type: "text",
32268
- text: formatFittingList(response.results ?? [], formatTicketWithSla, extractSearchPaginationMeta(response, per_page, page), offsetResume(page, per_page))
32334
+ text: formatFittingList(response.results ?? [], formatTicketWithSla, extractSearchPaginationMeta(response, per_page, page), offsetResume(page, per_page), (meta) => [resolved.note, formatPagination(meta)].filter(Boolean).join("\n"))
32269
32335
  }] };
32270
32336
  }
32271
32337
  },
@@ -32293,10 +32359,7 @@ const createTicketTools = (env) => {
32293
32359
  assignee_id: number$1().int().optional().describe("User id of the agent to assign the ticket to."),
32294
32360
  group_id: number$1().int().optional().describe("Id of the group to assign the ticket to."),
32295
32361
  tags: array(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."),
32296
- custom_fields: array(object({
32297
- id: number$1().int(),
32298
- value: unknown()
32299
- }).strict()).optional().describe("Custom field values as { id, value } pairs (field ids come from your Zendesk admin settings). Call list_ticket_fields first to discover the numeric field ids and, for dropdown/multiselect fields, the exact option values Zendesk accepts.")
32362
+ custom_fields: array(customFieldEntry).optional().describe("Custom field values as { id, value } pairs (field ids come from your Zendesk admin settings). Call list_ticket_fields first to discover the numeric field ids and, for dropdown/multiselect fields, the exact option values Zendesk accepts.")
32300
32363
  }),
32301
32364
  annotations: {
32302
32365
  readOnlyHint: false,
@@ -32350,10 +32413,7 @@ const createTicketTools = (env) => {
32350
32413
  group_id: number$1().int().optional().describe("Id of the group to assign the ticket to."),
32351
32414
  subject: string().optional().describe("New subject line for the ticket; replaces the current subject when provided."),
32352
32415
  tags: array(string()).optional().describe("Replaces the full tag set on the ticket. Use manage_tags for incremental add/remove."),
32353
- custom_fields: array(object({
32354
- id: number$1().int(),
32355
- value: unknown()
32356
- }).strict()).optional().describe("Custom field values as { id, value } pairs (field ids come from your Zendesk admin settings). Call list_ticket_fields first to discover the numeric field ids and, for dropdown/multiselect fields, the exact option values Zendesk accepts."),
32416
+ custom_fields: array(customFieldEntry).optional().describe("Custom field values as { id, value } pairs (field ids come from your Zendesk admin settings). Call list_ticket_fields first to discover the numeric field ids and, for dropdown/multiselect fields, the exact option values Zendesk accepts."),
32357
32417
  followers: object({
32358
32418
  add: subscriberIdList("User ids to start following the ticket. Omit to only remove."),
32359
32419
  remove: subscriberIdList("User ids to stop following the ticket. Omit to only add.")
@@ -33007,7 +33067,7 @@ const createUserTools = (env) => {
33007
33067
  namespace: "users",
33008
33068
  readOnly: true,
33009
33069
  title: "Search Zendesk Users",
33010
- description: "Search for users by name, email, or other criteria using Zendesk search query syntax. Returns total count.",
33070
+ description: "Search for users by name, email, or other criteria using Zendesk search query syntax, returning each match's id, name, email, role, active flag and organization id, with the total count. Use it to turn a person into the numeric user id other tools take, such as the followers of update_ticket; a name can match several users, so confirm the right one. With an id already in hand, call get_user to read that one user, and for your own account get_current_user. Prefer it over `search` when you want users only: `search` mixes in tickets and organizations unless the query says type:user.",
33011
33071
  inputSchema: object({
33012
33072
  query: 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."),
33013
33073
  per_page: number$1().int().min(1).max(100).default(100).describe(PER_PAGE_DESC),
@@ -35136,7 +35196,7 @@ const main = async () => {
35136
35196
  logger,
35137
35197
  cleanup: async () => (await starting?.catch(() => void 0))?.close()
35138
35198
  });
35139
- const { startHttpTransport } = await import("./http-DLa2DU80.js");
35199
+ const { startHttpTransport } = await import("./http-D-Xy9BWD.js");
35140
35200
  starting = startHttpTransport(config, logger);
35141
35201
  await starting;
35142
35202
  };
@@ -35146,4 +35206,4 @@ main().catch((error) => {
35146
35206
  process.exit(1);
35147
35207
  });
35148
35208
  //#endregion
35149
- export { createStartupError as A, WebStandardStreamableHTTPServerTransport as C, OAuthError as D, verifyBearerToken as E, OAuthErrorCode as O, DEFAULT_MAX_REQUEST_BODY_SIZE as S, isLegacyRequest as T, zendeskGet as _, toolEnv as a, DEFAULT_CALLBACK_PORT as b, clearToken as c, resolveTokenPath as d, saveToken as f, createAllTools as g, silentLogger as h, defaultServerFactory as i, errnoCode as j, STARTUP_DOCS as k, configDir as l, createRequestStateCodec as m, listenedUris as n, watchGrantAlerts as o, signInRequiredError as p, createServerCaches as r, startSlaRuntime as s, createListenFeed as t, loadToken as u, REQUEST_TIMEOUT_MS as v, createMcpHandler as w, getOAuthUrls as x, deadlineSignal as y };
35209
+ export { STARTUP_DOCS as A, WebStandardStreamableHTTPServerTransport as C, OAuthError as D, verifyBearerToken as E, errnoCode as M, OAuthErrorCode as O, DEFAULT_MAX_REQUEST_BODY_SIZE as S, isLegacyRequest as T, zendeskGet as _, toolEnv as a, DEFAULT_CALLBACK_PORT as b, clearToken as c, resolveTokenPath as d, saveToken as f, createAllTools as g, silentLogger as h, defaultServerFactory as i, createStartupError as j, compareCodeUnits as k, configDir as l, createRequestStateCodec as m, listenedUris as n, watchGrantAlerts as o, signInRequiredError as p, createServerCaches as r, startSlaRuntime as s, createListenFeed as t, loadToken as u, REQUEST_TIMEOUT_MS as v, createMcpHandler as w, getOAuthUrls as x, deadlineSignal as y };
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.6",
4
- "serialNumber": "urn:uuid:a211fb6c-e170-5986-a3aa-bdfae383496b",
4
+ "serialNumber": "urn:uuid:081eda00-f41f-539e-8e22-325ebea4a10a",
5
5
  "version": 1,
6
6
  "metadata": {
7
7
  "component": {
8
8
  "type": "application",
9
- "bom-ref": "pkg:npm/%40fruggr/zendesk-mcp-server@3.5.3",
9
+ "bom-ref": "pkg:npm/%40fruggr/zendesk-mcp-server@3.7.0",
10
10
  "name": "@fruggr/zendesk-mcp-server",
11
- "version": "3.5.3",
12
- "purl": "pkg:npm/%40fruggr/zendesk-mcp-server@3.5.3"
11
+ "version": "3.7.0",
12
+ "purl": "pkg:npm/%40fruggr/zendesk-mcp-server@3.7.0"
13
13
  }
14
14
  },
15
15
  "components": [
@@ -927,10 +927,10 @@
927
927
  },
928
928
  {
929
929
  "type": "library",
930
- "bom-ref": "pkg:npm/jose@6.2.12",
930
+ "bom-ref": "pkg:npm/jose@6.2.13",
931
931
  "name": "jose",
932
- "version": "6.2.12",
933
- "purl": "pkg:npm/jose@6.2.12",
932
+ "version": "6.2.13",
933
+ "purl": "pkg:npm/jose@6.2.13",
934
934
  "licenses": [
935
935
  {
936
936
  "expression": "MIT"
@@ -1539,10 +1539,10 @@
1539
1539
  },
1540
1540
  {
1541
1541
  "type": "library",
1542
- "bom-ref": "pkg:npm/oidc-provider@9.12.2",
1542
+ "bom-ref": "pkg:npm/oidc-provider@9.13.0",
1543
1543
  "name": "oidc-provider",
1544
- "version": "9.12.2",
1545
- "purl": "pkg:npm/oidc-provider@9.12.2",
1544
+ "version": "9.13.0",
1545
+ "purl": "pkg:npm/oidc-provider@9.13.0",
1546
1546
  "licenses": [
1547
1547
  {
1548
1548
  "expression": "MIT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fruggr/zendesk-mcp-server",
3
- "version": "3.5.3",
3
+ "version": "3.7.0",
4
4
  "mcpName": "io.github.fruggr/zendesk-mcp-server",
5
5
  "description": "Deep Zendesk MCP server for your AI assistant: search, draft, update and translate Help Center articles and manage Support tickets end to end — comments, triage and image attachments.",
6
6
  "type": "module",
@@ -21,8 +21,8 @@
21
21
  "dev:http": "tsx src/index.ts --transport http --host 127.0.0.1 --port 3000 --public-url http://localhost:3000 --mode all --namespace tickets --namespace help_center --namespace users --namespace requests",
22
22
  "prepare": "npm run build && (lefthook install || true)",
23
23
  "prepublishOnly": "npm run build",
24
- "check": "node scripts/biome.mjs check --error-on-warnings .",
25
- "check:fix": "node scripts/biome.mjs check --write .",
24
+ "check": "biome check --error-on-warnings .",
25
+ "check:fix": "biome check --write .",
26
26
  "test": "vitest run",
27
27
  "test:watch": "vitest watch",
28
28
  "test:coverage": "vitest run --coverage",
@@ -33,7 +33,8 @@
33
33
  "test:smoke": "pnpm build && node scripts/smoke-test.mjs",
34
34
  "test:bundle": "pnpm build && vitest run --config vitest.bundle.config.ts",
35
35
  "bundle:size": "pnpm build && node scripts/bundle-size.mjs",
36
- "mcp:live": "tsx scripts/mcp-live.ts"
36
+ "mcp:live": "tsx scripts/mcp-live.ts",
37
+ "tool-definitions:report": "tsx scripts/tool-definitions-report.ts"
37
38
  },
38
39
  "keywords": [
39
40
  "mcp",
@@ -73,9 +74,9 @@
73
74
  "engines": {
74
75
  "node": ">=22"
75
76
  },
76
- "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61",
77
+ "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1",
77
78
  "devDependencies": {
78
- "@biomejs/biome": "2.5.15",
79
+ "@biomejs/biome": "2.6.2",
79
80
  "@modelcontextprotocol/client": "2.3.1",
80
81
  "@modelcontextprotocol/node": "2.1.1",
81
82
  "@modelcontextprotocol/server": "2.3.1",
@@ -97,10 +98,11 @@
97
98
  "conventional-changelog-conventionalcommits": "^10.2.1",
98
99
  "hast-util-to-html": "9.0.5",
99
100
  "hast-util-to-mdast": "10.1.2",
100
- "jose": "6.2.12",
101
+ "jose": "6.2.13",
101
102
  "lefthook": "^2.2.2",
103
+ "mcp-tdqs": "0.2.0",
102
104
  "msw": "^3.0.0",
103
- "oidc-provider": "~9.12.2",
105
+ "oidc-provider": "~9.13.0",
104
106
  "open": "11.0.4",
105
107
  "rehype-parse": "9.0.1",
106
108
  "rehype-raw": "7.0.0",