@fruggr/zendesk-mcp-server 2.2.0 → 2.3.1

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
@@ -7,20 +7,55 @@
7
7
  [![Renovate enabled](https://img.shields.io/badge/renovate-enabled-brightgreen?logo=renovatebot&logoColor=white)](https://renovatebot.com)
8
8
  [![semantic-release](https://img.shields.io/badge/semantic--release-e10079?logo=semantic-release&logoColor=white)](https://github.com/semantic-release/semantic-release)
9
9
 
10
- A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that connects LLMs to the **Zendesk Support & Help Center APIs** with per-user OAuth 2.1 PKCE authentication and fine-grained tool visibility controls. Runs locally over **stdio** or as a private **remote MCP server** over HTTP.
11
-
12
- ## Why this server?
13
-
14
- Most Zendesk integrations use a shared admin API key, giving every user full access to every ticket. This server takes a different approach:
15
-
16
- - **Per-user authentication, OAuth-only** — In both transports, auth is OAuth 2.1 PKCE: each user authenticates with their own Zendesk credentials, so the LLM sees exactly what the user is allowed to see. Static API tokens are deliberately **not** supported (see [below](#what-this-server-does-not-do)).
10
+ **Bring Zendesk Support & the Help Center into your AI assistant.** A
11
+ [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that lets
12
+ your assistant search articles, answer questions, and create, track and update
13
+ tickets in plain language — **without switching apps**.
14
+
15
+ 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),
16
+ but **vendor-neutral** — it drops into any MCP client (Claude Desktop, Claude
17
+ Code, Cursor, VS Code, …) instead of being tied to one assistant — and it always
18
+ acts with **each user's own Zendesk permissions**, never a shared admin key.
19
+
20
+ ## What your assistant can do
21
+
22
+ Ask in natural language; the assistant figures out context and intent, then calls
23
+ the right tools on your behalf:
24
+
25
+ - **Find answers in the Help Center** — "how do I request a software license?" or
26
+ "what's the time-off policy?" surfaces the right article, by meaning, not just
27
+ keywords.
28
+ - **Create, view and update tickets without leaving the conversation** — open a
29
+ ticket, check its status, add a public reply or an internal note, change the
30
+ priority or assignee, or mark it solved.
31
+ - **Summarize a ticket for reporting or a quick decision** — pull the details and
32
+ the full comment thread and get the gist in a sentence.
33
+ - **Search and triage your queue in plain language** — "show me my open tickets
34
+ about billing from this week."
35
+ - **Draft and maintain knowledge-base articles** — write a new article, or revise
36
+ a large one **one section at a time** so the whole HTML body never has to
37
+ round-trip through the model.
38
+
39
+ Because it runs on the **user's own OAuth session**, the assistant only ever sees
40
+ and touches what that person is allowed to — the same scoping you'd get signing
41
+ into Zendesk directly.
42
+
43
+ ## How it's different
44
+
45
+ Most Zendesk integrations use a shared admin API key, giving every user full
46
+ access to every ticket, and bolt on a fixed set of tools. This server is built
47
+ differently:
48
+
49
+ - **Per-user authentication, OAuth-only** — In both transports, auth is OAuth 2.1 PKCE: each user authenticates with their own Zendesk credentials, so the assistant sees exactly what the user is allowed to see. Static API tokens are deliberately **not** supported (see [below](#what-this-server-does-not-do)).
17
50
  - **Two deployment shapes, same auth story** — Run it on your laptop as a stdio MCP server (Claude Desktop / Claude Code / VS Code) or deploy it as a private remote MCP server with one user, one Zendesk session per HTTP request.
18
51
  - **Context-friendly tool modes** — Expose every operation as its own tool, group them into namespace proxies, or collapse to a single unified tool. Tools are segmented into namespaces you can selectively enable, so each context loads only the surface it needs.
19
- - **Section-based article editing** — For large Help Center articles, read and rewrite one section at a time (parsed by h1/h2/h3 headings) instead of shuffling the full HTML body through the LLM. Reduces tokens by 10–100× on targeted edits.
52
+ - **Section-based article editing** — For large Help Center articles, read and rewrite one section at a time (parsed by h1/h2/h3 headings) instead of shuffling the full HTML body through the assistant. Reduces tokens by 10–100× on targeted edits.
20
53
  - **Read-only mode** — Restrict the server to read operations only, ideal for assistants that should never modify data.
21
54
  - **Lean stack** — Built on the official `@modelcontextprotocol/sdk` plus `zod`.
22
55
 
23
- > Built and maintained by [Digital4better](https://digital4better.com) for the [Fruggr](https://www.fruggr.io) project.
56
+ Under the hood it speaks to the **Zendesk Support & Help Center (Guide) APIs**,
57
+ runs locally over **stdio** or as a private **remote MCP server** over HTTP, and
58
+ ships fine-grained tool-visibility controls — the specifics are below.
24
59
 
25
60
  ## When to use this server
26
61
 
@@ -87,11 +122,12 @@ zendesk-mcp-server acme --namespace tickets
87
122
 
88
123
  | Tool | Description | Mode |
89
124
  |------|-------------|------|
90
- | `get_ticket` | Retrieve a ticket by ID with optional comments | read |
125
+ | `get_ticket` | Retrieve a ticket by ID with optional comments and its live SLA state (resolved via a scoped search) | read |
91
126
  | `get_ticket_attachments` | Download ticket attachments (images as base64, others as references) | read |
92
- | `search_tickets` | Search tickets using Zendesk query syntax | read |
127
+ | `search_tickets` | Search tickets using Zendesk query syntax, with per-result SLA state | read |
93
128
  | `list_tickets` | List tickets with cursor-based pagination | read |
94
129
  | `get_linked_incidents` | Get incidents linked to a problem ticket | read |
130
+ | `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 |
95
131
  | `create_ticket` | Create a new ticket with subject, description, priority, tags... | write |
96
132
  | `update_ticket` | Update ticket status, priority, assignee, tags, custom fields | write |
97
133
  | `add_private_note` | Add an internal note (not visible to requester) | write |
@@ -651,3 +687,7 @@ Every PR is reviewed automatically by [CodeRabbit](https://www.coderabbit.ai) in
651
687
  ## License
652
688
 
653
689
  [MIT](LICENSE)
690
+
691
+ ---
692
+
693
+ > Built and maintained by [Digital4better](https://digital4better.com) for the [Fruggr](https://www.fruggr.io) project.
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",
@@ -846,6 +846,43 @@ const formatTicket = (ticket) => [
846
846
  `- **Created**: ${ticket.created_at} | **Updated**: ${ticket.updated_at}`,
847
847
  ticket.description ? `\n${ticket.description}` : ""
848
848
  ].filter(Boolean).join("\n");
849
+ const formatConditionValue = (value) => value === null || value === void 0 ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
850
+ const formatSlaPolicy = (policy) => {
851
+ const conditions = [...policy.filter.all.map((c) => `all: ${c.field} ${c.operator} ${formatConditionValue(c.value)}`.trim()), ...policy.filter.any.map((c) => `any: ${c.field} ${c.operator} ${formatConditionValue(c.value)}`.trim())];
852
+ const targets = policy.policy_metrics.map((m) => ` - ${m.priority} / ${m.metric}: ${m.target} min${m.business_hours ? " (business)" : ""}`);
853
+ return [
854
+ `## SLA policy: ${policy.title} (${policy.id})`,
855
+ policy.description ? `- **Description**: ${policy.description}` : "",
856
+ `- **Position**: ${policy.position}`,
857
+ conditions.length > 0 ? `- **Conditions**: ${conditions.join("; ")}` : "",
858
+ targets.length > 0 ? "- **Targets**:" : "",
859
+ ...targets
860
+ ].filter(Boolean).join("\n");
861
+ };
862
+ const minutesUntil = (iso) => {
863
+ const t = Date.parse(iso);
864
+ return Number.isNaN(t) ? null : Math.round((t - Date.now()) / 6e4);
865
+ };
866
+ const formatSlaMetric = (m) => {
867
+ const stage = m.stage ?? "unknown";
868
+ const due = m.breach_at ?? null;
869
+ const parts = [`- **${m.metric}** — ${stage}`];
870
+ if (due) {
871
+ const remaining = minutesUntil(due);
872
+ if (stage === "paused" || stage === "achieved" || stage === "fulfilled" || remaining == null) parts.push(`due ${due}`);
873
+ else if (remaining < 0) parts.push(`due ${due} — breached (${Math.abs(remaining)} min overdue)`);
874
+ else parts.push(`due ${due} — ${remaining} min remaining`);
875
+ }
876
+ return parts.join("; ");
877
+ };
878
+ const formatSlaBlock = (entry) => {
879
+ if (!entry?.policy_metrics || entry.policy_metrics.length === 0) return "";
880
+ const lines = ["### SLA"];
881
+ const futureBreaches = entry.policy_metrics.map((m) => m.breach_at).map((d) => d ? Date.parse(d) : NaN).filter((t) => !Number.isNaN(t) && t > Date.now());
882
+ if (futureBreaches.length > 0) lines.push(`- **Next breach**: ${new Date(Math.min(...futureBreaches)).toISOString()}`);
883
+ for (const m of entry.policy_metrics) lines.push(formatSlaMetric(m));
884
+ return `\n\n${lines.join("\n")}`;
885
+ };
849
886
  const formatComment = (comment) => {
850
887
  const lines = [`### ${comment.public ? "Public comment" : "Internal note"} by ${comment.author_id}`, `*${comment.created_at}*`];
851
888
  if (comment.attachments?.length) {
@@ -915,10 +952,10 @@ const buildOffsetParams = (perPage, page) => {
915
952
  if (page && page > 1) params["page"] = String(page);
916
953
  return params;
917
954
  };
918
- const extractPaginationMeta = (response) => ({
955
+ const extractPaginationMeta = (response, itemCount) => ({
919
956
  has_more: response.meta?.has_more ?? response.next_page != null,
920
957
  after_cursor: response.meta?.after_cursor ?? null,
921
- count: response.count ?? 0
958
+ count: response.count ?? itemCount
922
959
  });
923
960
  const extractSearchPaginationMeta = (response, perPage, page) => {
924
961
  const count = response.count ?? 0;
@@ -947,13 +984,15 @@ const fetchTopology = async (subdomain, token) => {
947
984
  zendeskGet(subdomain, token, "/guide/permission_groups"),
948
985
  zendeskGet(subdomain, token, "/users/me")
949
986
  ]);
987
+ const categories = categoriesRes.categories ?? [];
988
+ const sections = sectionsRes.sections ?? [];
950
989
  return {
951
990
  subdomain,
952
991
  locales,
953
- categories: categoriesRes.categories ?? [],
954
- sections: sectionsRes.sections ?? [],
955
- sectionsHasMore: extractPaginationMeta(sectionsRes).has_more,
956
- 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,
957
996
  userSegments: segmentsRes.user_segments ?? [],
958
997
  permissionGroups: permsRes.permission_groups ?? [],
959
998
  currentUser: meRes.user
@@ -1055,7 +1094,7 @@ const groupByNamespace = (tools) => {
1055
1094
  };
1056
1095
  //#endregion
1057
1096
  //#region src/utils/article-sections.ts
1058
- const HEADING_LEVELS = new Set([
1097
+ const HEADING_LEVELS = /* @__PURE__ */ new Set([
1059
1098
  "h1",
1060
1099
  "h2",
1061
1100
  "h3"
@@ -1242,9 +1281,10 @@ const createHelpCenterTools = (ctx) => {
1242
1281
  handler: async (params) => {
1243
1282
  const { locale, page_size, cursor } = params;
1244
1283
  const response = await helpCenterGet(subdomain, await getToken(), locale ? `/${locale}/categories` : "/categories", buildCursorParams(page_size, cursor));
1284
+ const categories = response.categories ?? [];
1245
1285
  return { content: [{
1246
1286
  type: "text",
1247
- text: formatList(response.categories ?? [], formatCategory, extractPaginationMeta(response))
1287
+ text: formatList(categories, formatCategory, extractPaginationMeta(response, categories.length))
1248
1288
  }] };
1249
1289
  }
1250
1290
  },
@@ -1269,9 +1309,10 @@ const createHelpCenterTools = (ctx) => {
1269
1309
  handler: async (params) => {
1270
1310
  const { category_id, locale, page_size, cursor } = params;
1271
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 ?? [];
1272
1313
  return { content: [{
1273
1314
  type: "text",
1274
- text: formatList(response.sections ?? [], formatSection, extractPaginationMeta(response))
1315
+ text: formatList(sections, formatSection, extractPaginationMeta(response, sections.length))
1275
1316
  }] };
1276
1317
  }
1277
1318
  },
@@ -1284,8 +1325,8 @@ const createHelpCenterTools = (ctx) => {
1284
1325
  inputSchema: z.object({
1285
1326
  section_id: z.number().int().optional(),
1286
1327
  locale: z.string().optional(),
1287
- page_size: z.number().int().min(1).max(100).default(100),
1288
- 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."),
1289
1330
  sort_by: z.enum([
1290
1331
  "created_at",
1291
1332
  "updated_at",
@@ -1312,14 +1353,14 @@ const createHelpCenterTools = (ctx) => {
1312
1353
  const articles = response.articles ?? [];
1313
1354
  if (!include_translations) return { content: [{
1314
1355
  type: "text",
1315
- text: formatList(articles, formatArticleSummary, extractPaginationMeta(response))
1356
+ text: formatList(articles, formatArticleSummary, extractPaginationMeta(response, articles.length))
1316
1357
  }] };
1317
1358
  const formatted = await Promise.all(articles.map(async (article) => {
1318
1359
  const { translations } = await helpCenterGet(subdomain, token, `/articles/${article.id}/translations`);
1319
1360
  const locales = translations.map((t) => t.locale).join(", ");
1320
1361
  return `${formatArticleSummary(article)}\n- **Translations**: ${locales}`;
1321
1362
  }));
1322
- const meta = extractPaginationMeta(response);
1363
+ const meta = extractPaginationMeta(response, articles.length);
1323
1364
  return { content: [{
1324
1365
  type: "text",
1325
1366
  text: truncateIfNeeded([meta.count ? `Results: ${meta.count}${meta.has_more ? ` | More available (cursor: ${meta.after_cursor})` : ""}` : "", ...formatted].filter(Boolean).join("\n\n"))
@@ -1907,6 +1948,25 @@ const collectAttachmentBlocks = async (subdomain, token, attachments) => {
1907
1948
  }
1908
1949
  return blocks;
1909
1950
  };
1951
+ const fetchTicketSla = async (subdomain, token, ticket) => {
1952
+ const day = ticket.created_at.slice(0, 10);
1953
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return void 0;
1954
+ const shiftDay = (offset) => {
1955
+ const d = /* @__PURE__ */ new Date(`${day}T00:00:00Z`);
1956
+ d.setUTCDate(d.getUTCDate() + offset);
1957
+ return d.toISOString().slice(0, 10);
1958
+ };
1959
+ try {
1960
+ const { results } = await zendeskGet(subdomain, token, "/search", {
1961
+ query: `type:ticket requester:${ticket.requester_id} created>${shiftDay(-1)} created<${shiftDay(1)}`,
1962
+ include: "tickets(slas)",
1963
+ ...buildOffsetParams(100, 1)
1964
+ });
1965
+ return (results ?? []).find((r) => r.id === ticket.id)?.slas;
1966
+ } catch {
1967
+ return;
1968
+ }
1969
+ };
1910
1970
  const createTicketTools = (ctx) => {
1911
1971
  const { subdomain, getToken } = ctx;
1912
1972
  return [
@@ -1915,7 +1975,7 @@ const createTicketTools = (ctx) => {
1915
1975
  namespace: "tickets",
1916
1976
  readOnly: true,
1917
1977
  title: "Get Zendesk Ticket",
1918
- description: "Retrieve a Zendesk ticket by ID, including its comments if requested. Returns ticket details (subject, status, priority, assignee, tags, description) and optionally all comments/internal notes.",
1978
+ 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.",
1919
1979
  inputSchema: z.object({
1920
1980
  ticket_id: z.number().int().describe("Ticket ID"),
1921
1981
  include_comments: z.boolean().default(false).describe("Include ticket comments")
@@ -1930,7 +1990,7 @@ const createTicketTools = (ctx) => {
1930
1990
  const { ticket_id, include_comments } = params;
1931
1991
  const token = await getToken();
1932
1992
  const { ticket } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}`);
1933
- let text = formatTicket(ticket);
1993
+ let text = formatTicket(ticket) + formatSlaBlock(await fetchTicketSla(subdomain, token, ticket));
1934
1994
  if (include_comments) {
1935
1995
  const { comments } = await zendeskGet(subdomain, token, `/tickets/${ticket_id}/comments`);
1936
1996
  text += `\n\n---\n# Comments\n\n${comments.map(formatComment).join("\n\n")}`;
@@ -1986,7 +2046,7 @@ const createTicketTools = (ctx) => {
1986
2046
  namespace: "tickets",
1987
2047
  readOnly: true,
1988
2048
  title: "Search Zendesk Tickets",
1989
- description: "Search tickets using Zendesk query syntax (e.g., \"status:open assignee:me\", \"priority:urgent type:incident\"). Returns total count.",
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.",
1990
2050
  inputSchema: z.object({
1991
2051
  query: z.string().min(1).describe("Zendesk search query string"),
1992
2052
  per_page: z.number().int().min(1).max(100).default(100).describe("Results per page"),
@@ -2002,11 +2062,13 @@ const createTicketTools = (ctx) => {
2002
2062
  const { query, per_page, page } = params;
2003
2063
  const response = await zendeskGet(subdomain, await getToken(), "/search", {
2004
2064
  query: `type:ticket ${query}`,
2065
+ include: "tickets(slas)",
2005
2066
  ...buildOffsetParams(per_page, page)
2006
2067
  });
2068
+ const formatTicketWithSla = (ticket) => formatTicket(ticket) + formatSlaBlock(ticket.slas);
2007
2069
  return { content: [{
2008
2070
  type: "text",
2009
- text: formatList(response.results ?? [], formatTicket, extractSearchPaginationMeta(response, per_page, page))
2071
+ text: formatList(response.results ?? [], formatTicketWithSla, extractSearchPaginationMeta(response, per_page, page))
2010
2072
  }] };
2011
2073
  }
2012
2074
  },
@@ -2171,10 +2233,10 @@ const createTicketTools = (ctx) => {
2171
2233
  namespace: "tickets",
2172
2234
  readOnly: true,
2173
2235
  title: "List Zendesk Tickets",
2174
- 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.",
2175
2237
  inputSchema: z.object({
2176
- page_size: z.number().int().min(1).max(100).default(100),
2177
- 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.")
2178
2240
  }),
2179
2241
  annotations: {
2180
2242
  readOnlyHint: true,
@@ -2185,9 +2247,10 @@ const createTicketTools = (ctx) => {
2185
2247
  handler: async (params) => {
2186
2248
  const { page_size, cursor } = params;
2187
2249
  const response = await zendeskGet(subdomain, await getToken(), "/tickets", buildCursorParams(page_size, cursor));
2250
+ const tickets = response.tickets ?? [];
2188
2251
  return { content: [{
2189
2252
  type: "text",
2190
- text: formatList(response.tickets ?? [], formatTicket, extractPaginationMeta(response))
2253
+ text: formatList(tickets, formatTicket, extractPaginationMeta(response, tickets.length))
2191
2254
  }] };
2192
2255
  }
2193
2256
  },
@@ -2247,6 +2310,43 @@ const createTicketTools = (ctx) => {
2247
2310
  text: `Tags updated on ticket #${ticket_id}. Current: ${updated.tags.join(", ") || "none"}`
2248
2311
  }] };
2249
2312
  }
2313
+ },
2314
+ {
2315
+ name: "list_sla_policies",
2316
+ namespace: "tickets",
2317
+ readOnly: true,
2318
+ title: "List SLA Policies",
2319
+ 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
+ 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")
2323
+ }),
2324
+ annotations: {
2325
+ readOnlyHint: true,
2326
+ destructiveHint: false,
2327
+ idempotentHint: true,
2328
+ openWorldHint: true
2329
+ },
2330
+ handler: async (params) => {
2331
+ const { per_page, page } = params;
2332
+ const token = await getToken();
2333
+ let response;
2334
+ try {
2335
+ response = await zendeskGet(subdomain, token, "/slas/policies", buildOffsetParams(per_page, page));
2336
+ } catch (error) {
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 });
2338
+ throw error;
2339
+ }
2340
+ const policies = response.sla_policies ?? [];
2341
+ return { content: [{
2342
+ type: "text",
2343
+ text: formatList(policies, formatSlaPolicy, response.count != null ? extractSearchPaginationMeta(response, per_page, page) : {
2344
+ count: policies.length,
2345
+ has_more: false,
2346
+ after_cursor: null
2347
+ })
2348
+ }] };
2349
+ }
2250
2350
  }
2251
2351
  ];
2252
2352
  };
@@ -2368,9 +2468,10 @@ const createUserTools = (ctx) => {
2368
2468
  handler: async (params) => {
2369
2469
  const { page_size, cursor } = params;
2370
2470
  const response = await zendeskGet(subdomain, await getToken(), "/organizations", buildCursorParams(page_size, cursor));
2471
+ const organizations = response.organizations ?? [];
2371
2472
  return { content: [{
2372
2473
  type: "text",
2373
- text: formatList(response.organizations ?? [], formatOrganization, extractPaginationMeta(response))
2474
+ text: formatList(organizations, formatOrganization, extractPaginationMeta(response, organizations.length))
2374
2475
  }] };
2375
2476
  }
2376
2477
  }
@@ -2385,6 +2486,35 @@ const createAllTools = (ctx) => [
2385
2486
  ...createUserTools(ctx)
2386
2487
  ];
2387
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
2388
2518
  //#region src/server.ts
2389
2519
  /**
2390
2520
  * Invoke a tool handler, notifying `onUnauthorized` when Zendesk rejects the
@@ -2436,15 +2566,19 @@ const aggregateAnnotations = (tools) => ({
2436
2566
  });
2437
2567
  const buildProxyDispatch = (tools, onUnauthorized) => {
2438
2568
  const operationNames = tools.map((t) => t.name);
2439
- 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
+ }]));
2440
2573
  return async (args) => {
2441
2574
  const { operation, params } = args;
2442
- const def = localHandlers.get(operation);
2443
- if (!def) return { content: [{
2575
+ const entry = localHandlers.get(operation);
2576
+ if (!entry) return { content: [{
2444
2577
  type: "text",
2445
2578
  text: `Unknown operation "${operation}". Available: ${operationNames.join(", ")}`
2446
2579
  }] };
2447
- return runHandler(def, def.inputSchema.parse(params), onUnauthorized);
2580
+ const validated = entry.parseParams(params);
2581
+ return runHandler(entry.def, validated, onUnauthorized);
2448
2582
  };
2449
2583
  };
2450
2584
  const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnauthorized) => {
@@ -2487,7 +2621,7 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
2487
2621
  for (const tool of filteredTools) server.registerTool(tool.name, {
2488
2622
  title: tool.title,
2489
2623
  description: tool.description,
2490
- inputSchema: tool.inputSchema.shape,
2624
+ inputSchema: tool.inputSchema.strict(),
2491
2625
  annotations: tool.annotations
2492
2626
  }, async (params) => runHandler(tool, params, onUnauthorized));
2493
2627
  break;
@@ -2523,7 +2657,7 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
2523
2657
  };
2524
2658
  //#endregion
2525
2659
  //#region src/transports/http.ts
2526
- const WILDCARD_HOSTS = new Set([
2660
+ const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
2527
2661
  "0.0.0.0",
2528
2662
  "::",
2529
2663
  "*"
@@ -2542,12 +2676,12 @@ const CORS_ALLOWED_METHODS = "GET, POST, DELETE, OPTIONS";
2542
2676
  const CORS_ALLOWED_HEADERS = "Authorization, Content-Type, Accept, mcp-session-id, mcp-protocol-version, last-event-id";
2543
2677
  const CORS_EXPOSE_HEADERS = "mcp-session-id";
2544
2678
  const CORS_MAX_AGE = "600";
2545
- const LOCALHOST_HOSTNAMES = new Set([
2679
+ const LOCALHOST_HOSTNAMES = /* @__PURE__ */ new Set([
2546
2680
  "localhost",
2547
2681
  "127.0.0.1",
2548
2682
  "[::1]"
2549
2683
  ]);
2550
- const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
2684
+ const ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
2551
2685
  /**
2552
2686
  * Returns the origin string to reflect in `Access-Control-Allow-Origin`, or
2553
2687
  * `undefined` if the origin is not allowed.