@fruggr/zendesk-mcp-server 3.5.3 → 3.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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { i as __toESM, n as __exportAll, r as __require, t as __commonJSMin } from "./rolldown-runtime-BbXq-jo1.js";
3
- import { A as createStartupError, C as WebStandardStreamableHTTPServerTransport, D as OAuthError, E as verifyBearerToken, O as OAuthErrorCode, S as DEFAULT_MAX_REQUEST_BODY_SIZE, T as isLegacyRequest, _ as zendeskGet, a as toolEnv, g as createAllTools, h as silentLogger, i as defaultServerFactory, j as errnoCode, k as STARTUP_DOCS, l as configDir, m as createRequestStateCodec, n as listenedUris, o as watchGrantAlerts, r as createServerCaches, s as startSlaRuntime, t as createListenFeed, v as REQUEST_TIMEOUT_MS, w as createMcpHandler, x as getOAuthUrls, y as deadlineSignal } from "./index.js";
3
+ import { A as STARTUP_DOCS, C as WebStandardStreamableHTTPServerTransport, D as OAuthError, E as verifyBearerToken, M as errnoCode, O as OAuthErrorCode, S as DEFAULT_MAX_REQUEST_BODY_SIZE, T as isLegacyRequest, _ as zendeskGet, a as toolEnv, g as createAllTools, h as silentLogger, i as defaultServerFactory, j as createStartupError, k as compareCodeUnits, l as configDir, m as createRequestStateCodec, n as listenedUris, o as watchGrantAlerts, r as createServerCaches, s as startSlaRuntime, t as createListenFeed, v as REQUEST_TIMEOUT_MS, w as createMcpHandler, x as getOAuthUrls, y as deadlineSignal } from "./index.js";
4
4
  import { a as supportedScopes, i as requestedScope, n as generateCodeVerifier, o as escapeHtml, t as generateCodeChallenge } from "./pkce-DE_v9NQy.js";
5
5
  import * as util from "node:util";
6
6
  import { format, inspect } from "node:util";
@@ -2816,7 +2816,7 @@ const EXPIRED = "This sign-in has expired or was opened in another browser. Star
2816
2816
  const consentMemoryKey = (accountId, clientId, redirectUris) => createHash("sha256").update(JSON.stringify([
2817
2817
  accountId,
2818
2818
  clientId,
2819
- [...redirectUris].sort()
2819
+ [...redirectUris].sort(compareCodeUnits)
2820
2820
  ])).digest("base64url");
2821
2821
  const isExpiredLink = (err) => err instanceof Error && (err.name === "SessionNotFound" || err.name === "InvalidRequest");
2822
2822
  const UID = /^[\w-]{1,64}$/;
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}.`;
@@ -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;
@@ -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";
@@ -32244,7 +32278,7 @@ const createTicketTools = (env) => {
32244
32278
  title: "Search Zendesk Tickets",
32245
32279
  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
32280
  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)."),
32281
+ 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
32282
  per_page: number$1().int().min(1).max(100).default(25).describe(PER_PAGE_DESC),
32249
32283
  page: number$1().int().min(1).default(1).describe(PAGE_DESC)
32250
32284
  }),
@@ -32257,15 +32291,22 @@ const createTicketTools = (env) => {
32257
32291
  handler: async (params, { caller }) => {
32258
32292
  const { query, per_page, page } = params;
32259
32293
  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)]);
32294
+ const search = async () => {
32295
+ const resolved = await resolveCallerGroupMe(query, subdomain, token, caller.sla);
32296
+ return {
32297
+ resolved,
32298
+ response: await zendeskGet(subdomain, token, "/search", {
32299
+ query: `type:ticket ${resolved.query}`,
32300
+ include: "tickets(slas)",
32301
+ ...buildOffsetParams(per_page, page)
32302
+ })
32303
+ };
32304
+ };
32305
+ const [{ resolved, response }, urgency] = await Promise.all([search(), urgencyRenderer(caller.sla)]);
32265
32306
  const formatTicketWithSla = (ticket) => formatTicketSummary(ticket) + formatSlaBlock(ticket.slas, urgency(ticket.slas));
32266
32307
  return { content: [{
32267
32308
  type: "text",
32268
- text: formatFittingList(response.results ?? [], formatTicketWithSla, extractSearchPaginationMeta(response, per_page, page), offsetResume(page, per_page))
32309
+ text: formatFittingList(response.results ?? [], formatTicketWithSla, extractSearchPaginationMeta(response, per_page, page), offsetResume(page, per_page), (meta) => [resolved.note, formatPagination(meta)].filter(Boolean).join("\n"))
32269
32310
  }] };
32270
32311
  }
32271
32312
  },
@@ -35136,7 +35177,7 @@ const main = async () => {
35136
35177
  logger,
35137
35178
  cleanup: async () => (await starting?.catch(() => void 0))?.close()
35138
35179
  });
35139
- const { startHttpTransport } = await import("./http-DLa2DU80.js");
35180
+ const { startHttpTransport } = await import("./http-DkjvhtQj.js");
35140
35181
  starting = startHttpTransport(config, logger);
35141
35182
  await starting;
35142
35183
  };
@@ -35146,4 +35187,4 @@ main().catch((error) => {
35146
35187
  process.exit(1);
35147
35188
  });
35148
35189
  //#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 };
35190
+ 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:dbd14c1f-e90e-5bea-b648-7853fb8ee967",
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.6.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.6.0",
12
+ "purl": "pkg:npm/%40fruggr/zendesk-mcp-server@3.6.0"
13
13
  }
14
14
  },
15
15
  "components": [
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.6.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",
@@ -75,7 +75,7 @@
75
75
  },
76
76
  "packageManager": "pnpm@12.15.0+sha512.b3951d6d6ff4a9f13dc7cf56cd28d2b8eb37dbcc9c68e52e1e1191873ea9f82a017287ab50baadc5ccc494f036c67c0e47eb13608c2fa36f47711b61ea128e61",
77
77
  "devDependencies": {
78
- "@biomejs/biome": "2.5.15",
78
+ "@biomejs/biome": "2.6.2",
79
79
  "@modelcontextprotocol/client": "2.3.1",
80
80
  "@modelcontextprotocol/node": "2.1.1",
81
81
  "@modelcontextprotocol/server": "2.3.1",