@fruggr/zendesk-mcp-server 2.3.0 → 2.4.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
@@ -1,6 +1,7 @@
1
1
  # Zendesk MCP Server
2
2
 
3
3
  [![Glama score](https://glama.ai/mcp/servers/fruggr/zendesk-mcp-server/badges/score.svg)](https://glama.ai/mcp/servers/fruggr/zendesk-mcp-server)
4
+ [![MCP Registry](https://img.shields.io/badge/MCP_Registry-io.github.fruggr%2Fzendesk--mcp--server-0a7ea4)](https://registry.modelcontextprotocol.io/?search=io.github.fruggr/zendesk-mcp-server)
4
5
  [![npm version](https://img.shields.io/npm/v/@fruggr/zendesk-mcp-server?logo=npm&color=cb3837)](https://www.npmjs.com/package/@fruggr/zendesk-mcp-server)
5
6
  [![License: MIT](https://img.shields.io/npm/l/@fruggr/zendesk-mcp-server?color=blue)](LICENSE)
6
7
  [![Node.js](https://img.shields.io/node/v/@fruggr/zendesk-mcp-server?logo=nodedotjs&logoColor=white&color=339933)](https://nodejs.org)
@@ -622,7 +623,7 @@ This project was built with reference to:
622
623
 
623
624
  ## Releases & versioning
624
625
 
625
- Versions follow [SemVer](https://semver.org/) and are calculated **automatically** from commit messages — no one bumps the version by hand. Every merge to `main` triggers [semantic-release](https://github.com/semantic-release/semantic-release), which inspects the new [Conventional Commits](https://www.conventionalcommits.org/) since the previous tag, computes the next version, updates [`CHANGELOG.md`](CHANGELOG.md), publishes to npm, and creates the matching GitHub Release.
626
+ Versions follow [SemVer](https://semver.org/) and are calculated **automatically** from commit messages — no one bumps the version by hand. Every merge to `main` triggers [semantic-release](https://github.com/semantic-release/semantic-release), which inspects the new [Conventional Commits](https://www.conventionalcommits.org/) since the previous tag, computes the next version, updates [`CHANGELOG.md`](CHANGELOG.md), publishes to npm, creates the matching GitHub Release, and mirrors the release into the [official MCP registry](https://registry.modelcontextprotocol.io) as [`io.github.fruggr/zendesk-mcp-server`](https://registry.modelcontextprotocol.io/?search=io.github.fruggr/zendesk-mcp-server) so registry-driven clients discover the new version automatically.
626
627
 
627
628
  | Commit type | Resulting bump |
628
629
  |---|---|
package/dist/index.js CHANGED
@@ -34,7 +34,7 @@ const MCP_LEVEL = {
34
34
  warn: "warning",
35
35
  error: "error"
36
36
  };
37
- const REDACTED_KEYS = new Set([
37
+ const REDACTED_KEYS = /* @__PURE__ */ new Set([
38
38
  "token",
39
39
  "accesstoken",
40
40
  "refreshtoken",
@@ -952,10 +952,10 @@ const buildOffsetParams = (perPage, page) => {
952
952
  if (page && page > 1) params["page"] = String(page);
953
953
  return params;
954
954
  };
955
- const extractPaginationMeta = (response) => ({
955
+ const extractPaginationMeta = (response, itemCount) => ({
956
956
  has_more: response.meta?.has_more ?? response.next_page != null,
957
957
  after_cursor: response.meta?.after_cursor ?? null,
958
- count: response.count ?? 0
958
+ count: response.count ?? itemCount
959
959
  });
960
960
  const extractSearchPaginationMeta = (response, perPage, page) => {
961
961
  const count = response.count ?? 0;
@@ -984,13 +984,15 @@ const fetchTopology = async (subdomain, token) => {
984
984
  zendeskGet(subdomain, token, "/guide/permission_groups"),
985
985
  zendeskGet(subdomain, token, "/users/me")
986
986
  ]);
987
+ const categories = categoriesRes.categories ?? [];
988
+ const sections = sectionsRes.sections ?? [];
987
989
  return {
988
990
  subdomain,
989
991
  locales,
990
- categories: categoriesRes.categories ?? [],
991
- sections: sectionsRes.sections ?? [],
992
- sectionsHasMore: extractPaginationMeta(sectionsRes).has_more,
993
- categoriesHasMore: extractPaginationMeta(categoriesRes).has_more,
992
+ categories,
993
+ sections,
994
+ sectionsHasMore: extractPaginationMeta(sectionsRes, sections.length).has_more,
995
+ categoriesHasMore: extractPaginationMeta(categoriesRes, categories.length).has_more,
994
996
  userSegments: segmentsRes.user_segments ?? [],
995
997
  permissionGroups: permsRes.permission_groups ?? [],
996
998
  currentUser: meRes.user
@@ -1092,7 +1094,7 @@ const groupByNamespace = (tools) => {
1092
1094
  };
1093
1095
  //#endregion
1094
1096
  //#region src/utils/article-sections.ts
1095
- const HEADING_LEVELS = new Set([
1097
+ const HEADING_LEVELS = /* @__PURE__ */ new Set([
1096
1098
  "h1",
1097
1099
  "h2",
1098
1100
  "h3"
@@ -1279,9 +1281,10 @@ const createHelpCenterTools = (ctx) => {
1279
1281
  handler: async (params) => {
1280
1282
  const { locale, page_size, cursor } = params;
1281
1283
  const response = await helpCenterGet(subdomain, await getToken(), locale ? `/${locale}/categories` : "/categories", buildCursorParams(page_size, cursor));
1284
+ const categories = response.categories ?? [];
1282
1285
  return { content: [{
1283
1286
  type: "text",
1284
- text: formatList(response.categories ?? [], formatCategory, extractPaginationMeta(response))
1287
+ text: formatList(categories, formatCategory, extractPaginationMeta(response, categories.length))
1285
1288
  }] };
1286
1289
  }
1287
1290
  },
@@ -1306,9 +1309,10 @@ const createHelpCenterTools = (ctx) => {
1306
1309
  handler: async (params) => {
1307
1310
  const { category_id, locale, page_size, cursor } = params;
1308
1311
  const response = await helpCenterGet(subdomain, await getToken(), category_id && locale ? `/${locale}/categories/${category_id}/sections` : category_id ? `/categories/${category_id}/sections` : locale ? `/${locale}/sections` : "/sections", buildCursorParams(page_size, cursor));
1312
+ const sections = response.sections ?? [];
1309
1313
  return { content: [{
1310
1314
  type: "text",
1311
- text: formatList(response.sections ?? [], formatSection, extractPaginationMeta(response))
1315
+ text: formatList(sections, formatSection, extractPaginationMeta(response, sections.length))
1312
1316
  }] };
1313
1317
  }
1314
1318
  },
@@ -1321,8 +1325,8 @@ const createHelpCenterTools = (ctx) => {
1321
1325
  inputSchema: z.object({
1322
1326
  section_id: z.number().int().optional(),
1323
1327
  locale: z.string().optional(),
1324
- page_size: z.number().int().min(1).max(100).default(100),
1325
- cursor: z.string().optional(),
1328
+ page_size: z.number().int().min(1).max(100).default(100).describe("Articles per page (1-100, default 100)."),
1329
+ cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page."),
1326
1330
  sort_by: z.enum([
1327
1331
  "created_at",
1328
1332
  "updated_at",
@@ -1349,14 +1353,14 @@ const createHelpCenterTools = (ctx) => {
1349
1353
  const articles = response.articles ?? [];
1350
1354
  if (!include_translations) return { content: [{
1351
1355
  type: "text",
1352
- text: formatList(articles, formatArticleSummary, extractPaginationMeta(response))
1356
+ text: formatList(articles, formatArticleSummary, extractPaginationMeta(response, articles.length))
1353
1357
  }] };
1354
1358
  const formatted = await Promise.all(articles.map(async (article) => {
1355
1359
  const { translations } = await helpCenterGet(subdomain, token, `/articles/${article.id}/translations`);
1356
1360
  const locales = translations.map((t) => t.locale).join(", ");
1357
1361
  return `${formatArticleSummary(article)}\n- **Translations**: ${locales}`;
1358
1362
  }));
1359
- const meta = extractPaginationMeta(response);
1363
+ const meta = extractPaginationMeta(response, articles.length);
1360
1364
  return { content: [{
1361
1365
  type: "text",
1362
1366
  text: truncateIfNeeded([meta.count ? `Results: ${meta.count}${meta.has_more ? ` | More available (cursor: ${meta.after_cursor})` : ""}` : "", ...formatted].filter(Boolean).join("\n\n"))
@@ -2229,10 +2233,10 @@ const createTicketTools = (ctx) => {
2229
2233
  namespace: "tickets",
2230
2234
  readOnly: true,
2231
2235
  title: "List Zendesk Tickets",
2232
- description: "List tickets with cursor-based pagination, sorted by most recently updated.",
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.",
2233
2237
  inputSchema: z.object({
2234
- page_size: z.number().int().min(1).max(100).default(100),
2235
- cursor: z.string().optional().describe("Pagination cursor")
2238
+ page_size: z.number().int().min(1).max(100).default(100).describe("Tickets per page (1-100, default 100)."),
2239
+ cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page.")
2236
2240
  }),
2237
2241
  annotations: {
2238
2242
  readOnlyHint: true,
@@ -2243,9 +2247,10 @@ const createTicketTools = (ctx) => {
2243
2247
  handler: async (params) => {
2244
2248
  const { page_size, cursor } = params;
2245
2249
  const response = await zendeskGet(subdomain, await getToken(), "/tickets", buildCursorParams(page_size, cursor));
2250
+ const tickets = response.tickets ?? [];
2246
2251
  return { content: [{
2247
2252
  type: "text",
2248
- text: formatList(response.tickets ?? [], formatTicket, extractPaginationMeta(response))
2253
+ text: formatList(tickets, formatTicket, extractPaginationMeta(response, tickets.length))
2249
2254
  }] };
2250
2255
  }
2251
2256
  },
@@ -2329,7 +2334,7 @@ const createTicketTools = (ctx) => {
2329
2334
  try {
2330
2335
  response = await zendeskGet(subdomain, token, "/slas/policies", buildOffsetParams(per_page, page));
2331
2336
  } catch (error) {
2332
- if (error instanceof ZendeskApiError && error.status === 403) throw new Error("list_sla_policies reads SLA policy *configuration* (GET /slas/policies), which Zendesk restricts to admins (or a custom role granted the SLA-management permission). The current token lacks that permission (HTTP 403). This does not affect live SLA on tickets: per-metric SLA stage and breach countdown are available to any agent via get_ticket and search_tickets -- use those for triage and prioritization.");
2337
+ if (error instanceof ZendeskApiError && error.status === 403) throw new Error("list_sla_policies reads SLA policy *configuration* (GET /slas/policies), which Zendesk restricts to admins (or a custom role granted the SLA-management permission). The current token lacks that permission (HTTP 403). This does not affect live SLA on tickets: per-metric SLA stage and breach countdown are available to any agent via get_ticket and search_tickets -- use those for triage and prioritization.", { cause: error });
2333
2338
  throw error;
2334
2339
  }
2335
2340
  const policies = response.sla_policies ?? [];
@@ -2463,9 +2468,10 @@ const createUserTools = (ctx) => {
2463
2468
  handler: async (params) => {
2464
2469
  const { page_size, cursor } = params;
2465
2470
  const response = await zendeskGet(subdomain, await getToken(), "/organizations", buildCursorParams(page_size, cursor));
2471
+ const organizations = response.organizations ?? [];
2466
2472
  return { content: [{
2467
2473
  type: "text",
2468
- text: formatList(response.organizations ?? [], formatOrganization, extractPaginationMeta(response))
2474
+ text: formatList(organizations, formatOrganization, extractPaginationMeta(response, organizations.length))
2469
2475
  }] };
2470
2476
  }
2471
2477
  }
@@ -2480,6 +2486,35 @@ const createAllTools = (ctx) => [
2480
2486
  ...createUserTools(ctx)
2481
2487
  ];
2482
2488
  //#endregion
2489
+ //#region src/utils/validation.ts
2490
+ /**
2491
+ * Build a strict params parser for a tool's input schema, computing the strict
2492
+ * schema and the valid-key list once (at proxy-dispatch construction) rather
2493
+ * than per call.
2494
+ *
2495
+ * Zod objects default to `strip`, which silently drops unknown keys. That hid
2496
+ * #100: a caller passing `per_page` to list_tickets (whose parameter is
2497
+ * `page_size`) had the key dropped, so `page_size` fell back to its default and
2498
+ * a large unpaginated page came back. The returned parser rejects unknown keys
2499
+ * and rewrites the raw Zod error into a message that names the offending keys
2500
+ * and lists the valid parameters so a mistyped/misremembered name fails loudly.
2501
+ *
2502
+ * Used on the proxy dispatch path (namespace/single modes), where this code
2503
+ * owns the parse. In `all` mode the SDK validates against the strict schema we
2504
+ * register and produces its own (also explicit) "Unrecognized key" message.
2505
+ */
2506
+ const createStrictParamsParser = (schema) => {
2507
+ const strict = schema.strict();
2508
+ const validKeys = Object.keys(schema.shape).sort().join(", ");
2509
+ return (params) => {
2510
+ const result = strict.safeParse(params);
2511
+ if (result.success) return result.data;
2512
+ const unknownKeys = result.error.issues.filter((issue) => issue.code === "unrecognized_keys").flatMap((issue) => issue.keys ?? []);
2513
+ if (unknownKeys.length > 0) throw new Error(`Unknown parameter(s): ${unknownKeys.join(", ")}. Valid parameters: ${validKeys || "(none)"}.`);
2514
+ throw result.error;
2515
+ };
2516
+ };
2517
+ //#endregion
2483
2518
  //#region src/server.ts
2484
2519
  /**
2485
2520
  * Invoke a tool handler, notifying `onUnauthorized` when Zendesk rejects the
@@ -2531,15 +2566,19 @@ const aggregateAnnotations = (tools) => ({
2531
2566
  });
2532
2567
  const buildProxyDispatch = (tools, onUnauthorized) => {
2533
2568
  const operationNames = tools.map((t) => t.name);
2534
- const localHandlers = new Map(tools.map((t) => [t.name, t]));
2569
+ const localHandlers = new Map(tools.map((t) => [t.name, {
2570
+ def: t,
2571
+ parseParams: createStrictParamsParser(t.inputSchema)
2572
+ }]));
2535
2573
  return async (args) => {
2536
2574
  const { operation, params } = args;
2537
- const def = localHandlers.get(operation);
2538
- if (!def) return { content: [{
2575
+ const entry = localHandlers.get(operation);
2576
+ if (!entry) return { content: [{
2539
2577
  type: "text",
2540
2578
  text: `Unknown operation "${operation}". Available: ${operationNames.join(", ")}`
2541
2579
  }] };
2542
- return runHandler(def, def.inputSchema.parse(params), onUnauthorized);
2580
+ const validated = entry.parseParams(params);
2581
+ return runHandler(entry.def, validated, onUnauthorized);
2543
2582
  };
2544
2583
  };
2545
2584
  const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnauthorized) => {
@@ -2582,7 +2621,7 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
2582
2621
  for (const tool of filteredTools) server.registerTool(tool.name, {
2583
2622
  title: tool.title,
2584
2623
  description: tool.description,
2585
- inputSchema: tool.inputSchema.shape,
2624
+ inputSchema: tool.inputSchema.strict(),
2586
2625
  annotations: tool.annotations
2587
2626
  }, async (params) => runHandler(tool, params, onUnauthorized));
2588
2627
  break;
@@ -2618,7 +2657,7 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
2618
2657
  };
2619
2658
  //#endregion
2620
2659
  //#region src/transports/http.ts
2621
- const WILDCARD_HOSTS = new Set([
2660
+ const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
2622
2661
  "0.0.0.0",
2623
2662
  "::",
2624
2663
  "*"
@@ -2637,12 +2676,12 @@ const CORS_ALLOWED_METHODS = "GET, POST, DELETE, OPTIONS";
2637
2676
  const CORS_ALLOWED_HEADERS = "Authorization, Content-Type, Accept, mcp-session-id, mcp-protocol-version, last-event-id";
2638
2677
  const CORS_EXPOSE_HEADERS = "mcp-session-id";
2639
2678
  const CORS_MAX_AGE = "600";
2640
- const LOCALHOST_HOSTNAMES = new Set([
2679
+ const LOCALHOST_HOSTNAMES = /* @__PURE__ */ new Set([
2641
2680
  "localhost",
2642
2681
  "127.0.0.1",
2643
2682
  "[::1]"
2644
2683
  ]);
2645
- const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
2684
+ const ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
2646
2685
  /**
2647
2686
  * Returns the origin string to reflect in `Access-Control-Allow-Origin`, or
2648
2687
  * `undefined` if the origin is not allowed.