@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 +2 -1
- package/dist/index.js +67 -28
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Zendesk MCP Server
|
|
2
2
|
|
|
3
3
|
[](https://glama.ai/mcp/servers/fruggr/zendesk-mcp-server)
|
|
4
|
+
[](https://registry.modelcontextprotocol.io/?search=io.github.fruggr/zendesk-mcp-server)
|
|
4
5
|
[](https://www.npmjs.com/package/@fruggr/zendesk-mcp-server)
|
|
5
6
|
[](LICENSE)
|
|
6
7
|
[](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,
|
|
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 ??
|
|
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
|
|
991
|
-
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
|
2538
|
-
if (!
|
|
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
|
-
|
|
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.
|
|
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.
|