@fruggr/zendesk-mcp-server 2.20.1 → 2.21.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.
Files changed (3) hide show
  1. package/README.md +4 -0
  2. package/dist/index.js +167 -47
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -126,6 +126,10 @@ Signing in needs a Zendesk OAuth client, so register one first (next section).
126
126
  `ZENDESK_OAUTH_CALLBACK_PORT` / `--callback-port` if you override it; Zendesk
127
127
  accepts several redirect URLs, one per line)
128
128
 
129
+ If the client restricts its **allowed scopes**, it needs `read` — plus `write`
130
+ unless the server runs with [`--read-only`](docs/configuration.md), which asks
131
+ Zendesk for the `read` scope alone.
132
+
129
133
  On the first tool call the server starts the sign-in flow: it opens a browser
130
134
  window and returns the authorize URL in a tool message. The call does not block
131
135
  waiting for sign-in, so authenticate in the browser and then retry the request.
package/dist/index.js CHANGED
@@ -129,6 +129,11 @@ const CHARACTER_LIMIT = positiveIntEnv("ZENDESK_CHARACTER_LIMIT", 25e3);
129
129
  const ARTICLE_RESOURCES_SCAN_MAX_PAGES = positiveIntEnv("ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES", 20);
130
130
  const MAX_ATTACHMENT_BYTES = positiveIntEnv("ZENDESK_MAX_ATTACHMENT_BYTES", 5242880);
131
131
  const MAX_EMBEDDED_IMAGE_COUNT = positiveIntEnv("ZENDESK_MAX_EMBEDDED_IMAGES", 10);
132
+ const STDIO_MAX_MESSAGE_BYTES = 10485760;
133
+ const MESSAGE_CONTENT_BUDGET_BYTES = 10420224;
134
+ const MAX_RESPONSE_BYTES = Math.min(positiveIntEnv("ZENDESK_MAX_RESPONSE_BYTES", MESSAGE_CONTENT_BUDGET_BYTES), MESSAGE_CONTENT_BUDGET_BYTES);
135
+ const MAX_BASE64_INPUT_CHARS = MESSAGE_CONTENT_BUDGET_BYTES;
136
+ const MAX_BASE64_INPUT_MB = Number.parseFloat((MAX_BASE64_INPUT_CHARS / 4 * 3 / 1048576).toFixed(2));
132
137
  const MAX_COMMENT_PAGES = positiveIntEnv("ZENDESK_MAX_COMMENT_PAGES", 10);
133
138
  const REORDER_CONFIRM_THRESHOLD = positiveIntEnv("ZENDESK_REORDER_CONFIRM_THRESHOLD", 20);
134
139
  const getBaseUrl = (subdomain) => `https://${subdomain}.zendesk.com/api/v2`;
@@ -138,6 +143,48 @@ const getOAuthUrls = (subdomain) => ({
138
143
  tokenUrl: `https://${subdomain}.zendesk.com/oauth/tokens`
139
144
  });
140
145
  //#endregion
146
+ //#region src/auth/oauth-scopes.ts
147
+ /**
148
+ * The OAuth scope the server asks Zendesk for, and the one predicate that
149
+ * decides whether a cached token's grant is still good enough.
150
+ *
151
+ * Both scope strings live here rather than in `constants.ts` on purpose: they
152
+ * are behavioural strings whose mutants must be killed by assertions
153
+ * (`docs/decisions/mutation-testing.md`, "OAuth parameters and scopes"), and
154
+ * `src/auth/**` is inside the mutation scope while `constants.ts` is not.
155
+ * Keeping them next to the predicate that consumes them keeps both under the
156
+ * same gate.
157
+ */
158
+ const READ_SCOPE = "read";
159
+ const READ_WRITE_SCOPE = "read write";
160
+ const WHITESPACE = /\s+/;
161
+ const scopeTokens = (scope) => scope.split(WHITESPACE).filter((s) => s.length > 0);
162
+ /**
163
+ * The scope to request for a given tool surface. `--read-only` already filters
164
+ * every write tool out of the surface, so asking Zendesk for `write` on top of
165
+ * that would be requesting an authority the server cannot even exercise — and
166
+ * an OAuth client whose allowed scopes stop at `read` rejects the whole
167
+ * authorize request with `invalid_scope`, minting no token at all (#283).
168
+ */
169
+ const requestedScope = (readOnly) => readOnly ? READ_SCOPE : READ_WRITE_SCOPE;
170
+ /**
171
+ * The same decision as a list, for the RFC 9728 / RFC 8414 `scopes_supported`
172
+ * metadata. Derived from `requestedScope` so what the HTTP transport advertises
173
+ * provably cannot drift from what the stdio flow requests.
174
+ */
175
+ const supportedScopes = (readOnly) => scopeTokens(requestedScope(readOnly));
176
+ /**
177
+ * Whether a grant still covers what this process needs: a flat subset test.
178
+ * Coverage, not equality, so a broader token stays usable and two servers
179
+ * sharing a token file converge. A non-string `granted` is a pre-#283 record,
180
+ * i.e. `read write`. No scope hierarchy: granular scopes (#284) replace this.
181
+ */
182
+ const grantCovers = (granted, requested) => {
183
+ if (typeof granted !== "string") return true;
184
+ const held = new Set(scopeTokens(granted));
185
+ return scopeTokens(requested).every((token) => held.has(token));
186
+ };
187
+ //#endregion
141
188
  //#region src/auth/browser-oauth.ts
142
189
  const AUTH_TIMEOUT_MS = 3e5;
143
190
  /** Best-effort WSL detection: WSL kernels carry "microsoft" in /proc/version. */
@@ -180,7 +227,7 @@ const generateCodeChallenge = (verifier) => createHash("sha256").update(verifier
180
227
  * open for up to the 5-minute timeout.
181
228
  */
182
229
  const startBrowserAuth = (config, logger = silentLogger) => {
183
- const { subdomain, oauthClientId } = config;
230
+ const { subdomain, oauthClientId, readOnly } = config;
184
231
  const { authorizeUrl: authorizeBase, tokenUrl } = getOAuthUrls(subdomain);
185
232
  const codeVerifier = generateCodeVerifier();
186
233
  const codeChallenge = generateCodeChallenge(codeVerifier);
@@ -303,7 +350,7 @@ const startBrowserAuth = (config, logger = silentLogger) => {
303
350
  response_type: "code",
304
351
  client_id: oauthClientId,
305
352
  redirect_uri: redirectUri,
306
- scope: "read write",
353
+ scope: requestedScope(readOnly),
307
354
  code_challenge: codeChallenge,
308
355
  code_challenge_method: "S256"
309
356
  });
@@ -492,8 +539,18 @@ const createAuthRequiredError = (authorizeUrl) => Object.assign(/* @__PURE__ */
492
539
  const expiryFrom = (expiresIn) => typeof expiresIn === "number" ? Date.now() + expiresIn * 1e3 : void 0;
493
540
  const createTokenStore = (config, logger = silentLogger) => {
494
541
  const tokenPath = resolveTokenPath(config.subdomain);
542
+ const requested = requestedScope(config.readOnly);
495
543
  let token = loadToken(tokenPath);
496
- if (token) logger.debug("oauth_token_loaded_from_disk");
544
+ if (token) {
545
+ if (grantCovers(token.scope, requested)) logger.debug("oauth_token_loaded_from_disk");
546
+ else {
547
+ logger.warn("oauth_token_scope_insufficient", {
548
+ requested,
549
+ granted: token.scope
550
+ });
551
+ token = void 0;
552
+ }
553
+ }
497
554
  let authorizeUrl;
498
555
  let starting;
499
556
  let refreshing;
@@ -502,12 +559,20 @@ const createTokenStore = (config, logger = silentLogger) => {
502
559
  const setToken = (accessToken, refreshToken) => {
503
560
  token = {
504
561
  accessToken,
505
- refreshToken
562
+ refreshToken,
563
+ scope: requested
506
564
  };
507
565
  probedUnknownExpiry = false;
508
566
  persist(token);
509
567
  };
510
568
  const needsRefresh = (t) => typeof t.expiresAt === "number" ? Date.now() >= t.expiresAt - EXPIRY_SKEW_MS : t.refreshToken !== void 0 && !probedUnknownExpiry;
569
+ const noteGrant = (granted) => {
570
+ if (!grantCovers(granted, requested)) logger.warn("oauth_token_grant_narrowed", {
571
+ requested,
572
+ granted
573
+ });
574
+ return granted;
575
+ };
511
576
  const tryRefresh = async (current, { dropOnFailure = true } = {}) => {
512
577
  if (!current.refreshToken) return void 0;
513
578
  try {
@@ -519,7 +584,8 @@ const createTokenStore = (config, logger = silentLogger) => {
519
584
  token = {
520
585
  accessToken: result.access_token,
521
586
  refreshToken: result.refresh_token ?? current.refreshToken,
522
- expiresAt: expiryFrom(result.expires_in)
587
+ expiresAt: expiryFrom(result.expires_in),
588
+ scope: noteGrant(result.scope || current.scope)
523
589
  };
524
590
  probedUnknownExpiry = true;
525
591
  persist(token);
@@ -539,14 +605,16 @@ const createTokenStore = (config, logger = silentLogger) => {
539
605
  return startBrowserAuth({
540
606
  subdomain: config.subdomain,
541
607
  oauthClientId: config.oauthClientId,
542
- callbackPort: config.callbackPort
608
+ callbackPort: config.callbackPort,
609
+ readOnly: config.readOnly
543
610
  }, logger).then((started) => {
544
611
  authorizeUrl = started.authorizeUrl;
545
612
  started.tokenPromise.then((result) => {
546
613
  token = {
547
614
  accessToken: result.access_token,
548
615
  refreshToken: result.refresh_token,
549
- expiresAt: expiryFrom(result.expires_in)
616
+ expiresAt: expiryFrom(result.expires_in),
617
+ scope: noteGrant(result.scope || requested)
550
618
  };
551
619
  probedUnknownExpiry = true;
552
620
  persist(token);
@@ -588,7 +656,8 @@ const createTokenStore = (config, logger = silentLogger) => {
588
656
  token = {
589
657
  accessToken: token.accessToken,
590
658
  refreshToken: token.refreshToken,
591
- expiresAt: 0
659
+ expiresAt: 0,
660
+ scope: token.scope
592
661
  };
593
662
  persist(token);
594
663
  } else {
@@ -3083,7 +3152,10 @@ const createHelpCenterTools = (ctx) => {
3083
3152
  inputSchema: z.object({
3084
3153
  article_id: z.number().int().describe(ARTICLE_ID_DESC),
3085
3154
  file_name: z.string().min(1).describe("Name to store the file under, including its extension (e.g. \"screenshot.png\"); used as the download name."),
3086
- file_base64: z.string().min(1).describe("The file's raw bytes as a base64-encoded string; the server decodes them before upload."),
3155
+ file_base64: z.string().min(1).max(MAX_BASE64_INPUT_CHARS, {
3156
+ abort: true,
3157
+ error: (issue) => `File too large: ${issue.input.length} base64 characters, limit ${MAX_BASE64_INPUT_CHARS}. Downscale the file, split it, or host it elsewhere and link to it.`
3158
+ }).describe(`The file's raw bytes as a base64-encoded string; the server decodes them before upload. At most ${MAX_BASE64_INPUT_CHARS} characters (about ${MAX_BASE64_INPUT_MB} MB of file); the HTTP transport additionally caps request bodies at 4 MB.`),
3087
3159
  content_type: z.string().default("application/octet-stream").describe("MIME type of the file, e.g. \"image/png\" or \"application/pdf\". Defaults to application/octet-stream when omitted.")
3088
3160
  }),
3089
3161
  annotations: {
@@ -3163,6 +3235,28 @@ const createSearchTools = (ctx) => {
3163
3235
  //#endregion
3164
3236
  //#region src/tools/tickets.ts
3165
3237
  const MAX_ATTACHMENT_MB = Number.parseFloat((MAX_ATTACHMENT_BYTES / 1048576).toFixed(2));
3238
+ const MAX_RESPONSE_MB = Number.parseFloat((MAX_RESPONSE_BYTES / 1048576).toFixed(2));
3239
+ const blockCost = (block) => block.type === "image" ? Buffer.byteLength(JSON.stringify({
3240
+ ...block,
3241
+ data: ""
3242
+ }), "utf8") + block.data.length + 1 : Buffer.byteLength(JSON.stringify(block), "utf8") + 1;
3243
+ const embeddedCost = (attachment, reference) => blockCost({
3244
+ type: "image",
3245
+ data: "",
3246
+ mimeType: attachment.content_type
3247
+ }) + 4 * Math.ceil(attachment.size / 3) + blockCost({
3248
+ type: "text",
3249
+ text: reference
3250
+ });
3251
+ const BUDGET_REACHED = `skipped: response budget of ${MAX_RESPONSE_MB} MB reached`;
3252
+ const referenceBlock = (reference, skipReason) => ({
3253
+ type: "text",
3254
+ text: skipReason ? `${reference} — ${skipReason}` : reference
3255
+ });
3256
+ const truncationNotice = (omitted) => ({
3257
+ type: "text",
3258
+ text: `${omitted} further attachments omitted: response budget of ${MAX_RESPONSE_MB} MB reached`
3259
+ });
3166
3260
  const formatReference = (attachment) => `**${attachment.file_name}** (id ${attachment.id}, ${attachment.content_type}, ${attachment.size} bytes) — ${attachment.content_url}`;
3167
3261
  const buildEmbeddedImageBlocks = async (subdomain, token, attachment, reference) => {
3168
3262
  const { data, contentType } = await fetchZendeskBinary(subdomain, token, attachment.content_url);
@@ -3211,38 +3305,52 @@ const fetchAttachmentsByIds = async (subdomain, token, ids) => {
3211
3305
  }
3212
3306
  return attachments;
3213
3307
  };
3214
- const collectAttachmentBlocks = async (subdomain, token, attachments) => {
3308
+ const imageSkipReason = (attachment, embeddedCount, reference, fits) => {
3309
+ if (attachment.size > MAX_ATTACHMENT_BYTES) return `skipped: exceeds ${MAX_ATTACHMENT_MB} MB per-image limit`;
3310
+ if (embeddedCount >= MAX_EMBEDDED_IMAGE_COUNT) return `skipped: max ${MAX_EMBEDDED_IMAGE_COUNT} embedded images reached`;
3311
+ if (!fits(embeddedCost(attachment, reference))) return BUDGET_REACHED;
3312
+ return null;
3313
+ };
3314
+ const overflowFallback = (produced, reference, fits) => {
3315
+ if (!produced.some((block) => block.type === "image")) return null;
3316
+ const fallback = referenceBlock(reference, BUDGET_REACHED);
3317
+ return fits(blockCost(fallback)) ? fallback : null;
3318
+ };
3319
+ const embedOrExplain = async (subdomain, token, attachment, reference) => {
3320
+ try {
3321
+ return await buildEmbeddedImageBlocks(subdomain, token, attachment, reference);
3322
+ } catch (error) {
3323
+ return [{
3324
+ type: "text",
3325
+ text: `${reference} — ${error instanceof ZendeskApiError ? `download failed: ${error.status} ${error.statusText}` : "download failed"}`
3326
+ }];
3327
+ }
3328
+ };
3329
+ const collectAttachmentBlocks = async (subdomain, token, attachments, consumedBytes = 0) => {
3215
3330
  const blocks = [];
3216
3331
  let embeddedCount = 0;
3217
- for (const attachment of attachments) {
3332
+ let responseBytes = consumedBytes;
3333
+ const noticeReserve = blockCost(truncationNotice(attachments.length));
3334
+ const fits = (cost) => responseBytes + cost + noticeReserve <= MAX_RESPONSE_BYTES;
3335
+ for (const [index, attachment] of attachments.entries()) {
3218
3336
  const reference = formatReference(attachment);
3219
- if (!attachment.content_type.startsWith("image/")) {
3220
- blocks.push({
3221
- type: "text",
3222
- text: reference
3223
- });
3224
- continue;
3225
- }
3226
- let skipReason = null;
3227
- if (attachment.size > MAX_ATTACHMENT_BYTES) skipReason = `skipped: exceeds ${MAX_ATTACHMENT_MB} MB per-image limit`;
3228
- else if (embeddedCount >= MAX_EMBEDDED_IMAGE_COUNT) skipReason = `skipped: max ${MAX_EMBEDDED_IMAGE_COUNT} embedded images reached`;
3229
- if (skipReason) {
3230
- blocks.push({
3231
- type: "text",
3232
- text: `${reference} — ${skipReason}`
3233
- });
3234
- continue;
3235
- }
3236
- try {
3237
- blocks.push(...await buildEmbeddedImageBlocks(subdomain, token, attachment, reference));
3238
- embeddedCount += 1;
3239
- } catch (error) {
3240
- const reason = error instanceof ZendeskApiError ? `download failed: ${error.status} ${error.statusText}` : "download failed";
3241
- blocks.push({
3242
- type: "text",
3243
- text: `${reference} — ${reason}`
3244
- });
3337
+ const isImage = attachment.content_type.startsWith("image/");
3338
+ const skipReason = isImage ? imageSkipReason(attachment, embeddedCount, reference, fits) : null;
3339
+ const produced = isImage && !skipReason ? await embedOrExplain(subdomain, token, attachment, reference) : [referenceBlock(reference, skipReason)];
3340
+ const cost = produced.reduce((total, block) => total + blockCost(block), 0);
3341
+ if (!fits(cost)) {
3342
+ const fallback = overflowFallback(produced, reference, fits);
3343
+ if (fallback) {
3344
+ blocks.push(fallback);
3345
+ responseBytes += blockCost(fallback);
3346
+ continue;
3347
+ }
3348
+ blocks.push(truncationNotice(attachments.length - index));
3349
+ break;
3245
3350
  }
3351
+ blocks.push(...produced);
3352
+ responseBytes += cost;
3353
+ if (produced.some((block) => block.type === "image")) embeddedCount += 1;
3246
3354
  }
3247
3355
  return blocks;
3248
3356
  };
@@ -3437,9 +3545,19 @@ const createTicketTools = (ctx) => {
3437
3545
  const { subdomain, getToken } = ctx;
3438
3546
  const attachmentSchema = z.object({
3439
3547
  file_name: z.string().min(1).describe("File name, e.g. \"app.log\" or \"screenshot.png\"."),
3440
- file_base64: z.string().min(1).base64().describe("File content encoded as base64."),
3548
+ file_base64: z.string().min(1).max(MAX_BASE64_INPUT_CHARS, {
3549
+ abort: true,
3550
+ error: (issue) => `Attachment too large: ${issue.input.length} base64 characters, limit ${MAX_BASE64_INPUT_CHARS}. Downscale the file, split the upload, or link to it instead of uploading.`
3551
+ }).base64().describe(`File content encoded as base64. At most ${MAX_BASE64_INPUT_CHARS} characters (about ${MAX_BASE64_INPUT_MB} MB of file), and the attachments of one call must stay under that total; the HTTP transport additionally caps request bodies at 4 MB.`),
3441
3552
  content_type: z.string().min(1).default("application/octet-stream").describe("MIME type, e.g. \"text/plain\", \"image/png\", \"application/pdf\".")
3442
3553
  });
3554
+ const attachmentsParam = (description) => z.array(attachmentSchema).superRefine((files, refinement) => {
3555
+ const total = files.reduce((sum, file) => sum + file.file_base64.length, 0);
3556
+ if (total > 10420224) refinement.addIssue({
3557
+ code: "custom",
3558
+ message: `Attachments too large: ${total} base64 characters in total, limit ${MAX_BASE64_INPUT_CHARS}. Send fewer files per call.`
3559
+ });
3560
+ }).optional().describe(description);
3443
3561
  const uploadAttachments = async (token, files) => {
3444
3562
  let uploadToken;
3445
3563
  for (const file of files) {
@@ -3602,11 +3720,11 @@ const createTicketTools = (ctx) => {
3602
3720
  type: "text",
3603
3721
  text: `No attachments found on ticket #${ticket_id}.`
3604
3722
  }] };
3605
- const blocks = await collectAttachmentBlocks(subdomain, token, attachments);
3606
- return { content: [{
3723
+ const header = {
3607
3724
  type: "text",
3608
3725
  text: `# Attachments for ticket #${ticket_id} (${attachments.length} total)`
3609
- }, ...blocks] };
3726
+ };
3727
+ return { content: [header, ...await collectAttachmentBlocks(subdomain, token, attachments, blockCost(header) + 1)] };
3610
3728
  }
3611
3729
  },
3612
3730
  {
@@ -3752,7 +3870,7 @@ const createTicketTools = (ctx) => {
3752
3870
  inputSchema: z.object({
3753
3871
  ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket to annotate. Obtain it from search_tickets or list_tickets."),
3754
3872
  body: z.string().min(1).describe("Note text (internal, agent-only). Plain text or HTML; not shown to the requester."),
3755
- attachments: z.array(attachmentSchema).optional().describe("Files to attach to this note (base64-encoded content).")
3873
+ attachments: attachmentsParam("Files to attach to this note (base64-encoded content).")
3756
3874
  }),
3757
3875
  annotations: {
3758
3876
  readOnlyHint: false,
@@ -3784,7 +3902,7 @@ const createTicketTools = (ctx) => {
3784
3902
  inputSchema: z.object({
3785
3903
  ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket to reply on. Obtain it from search_tickets or list_tickets."),
3786
3904
  body: z.string().min(1).describe("Comment text sent to the requester. Plain text or HTML; visible in the ticket."),
3787
- attachments: z.array(attachmentSchema).optional().describe("Files to attach to this comment (base64-encoded content).")
3905
+ attachments: attachmentsParam("Files to attach to this comment (base64-encoded content).")
3788
3906
  }),
3789
3907
  annotations: {
3790
3908
  readOnlyHint: false,
@@ -4490,7 +4608,7 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
4490
4608
  //#endregion
4491
4609
  //#region src/transports/stdio.ts
4492
4610
  const startStdioTransport = async (server, logger = silentLogger) => {
4493
- const transport = new StdioServerTransport();
4611
+ const transport = new StdioServerTransport(void 0, void 0, { maxBufferSize: STDIO_MAX_MESSAGE_BYTES });
4494
4612
  await server.connect(transport);
4495
4613
  logger.info("stdio_transport_ready");
4496
4614
  };
@@ -4737,12 +4855,13 @@ const buildOAuthMetadata = (config, logger = silentLogger) => {
4737
4855
  const { authorizeUrl, tokenUrl } = getOAuthUrls(config.subdomain);
4738
4856
  const issuer = `https://${config.subdomain}.zendesk.com`;
4739
4857
  const resource = resolveResourceUrl(config, logger);
4858
+ const scopes = () => supportedScopes(config.readOnly);
4740
4859
  return {
4741
4860
  protectedResource: {
4742
4861
  authorization_servers: [issuer],
4743
4862
  resource,
4744
4863
  bearer_methods_supported: ["header"],
4745
- scopes_supported: ["read", "write"]
4864
+ scopes_supported: scopes()
4746
4865
  },
4747
4866
  authorizationServer: {
4748
4867
  issuer,
@@ -4752,7 +4871,7 @@ const buildOAuthMetadata = (config, logger = silentLogger) => {
4752
4871
  grant_types_supported: ["authorization_code", "refresh_token"],
4753
4872
  code_challenge_methods_supported: ["S256"],
4754
4873
  token_endpoint_auth_methods_supported: ["none"],
4755
- scopes_supported: ["read", "write"]
4874
+ scopes_supported: scopes()
4756
4875
  }
4757
4876
  };
4758
4877
  };
@@ -5075,7 +5194,8 @@ const installShutdown = (options) => {
5075
5194
  const buildStdioTokenStore = (config, logger) => createTokenStore({
5076
5195
  subdomain: config.subdomain,
5077
5196
  oauthClientId: config.oauthClientId,
5078
- callbackPort: config.callbackPort
5197
+ callbackPort: config.callbackPort,
5198
+ readOnly: config.readOnly
5079
5199
  }, logger);
5080
5200
  const connectStdio = async (config, tokenStore, logger) => {
5081
5201
  if (config.dev) return startDevServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fruggr/zendesk-mcp-server",
3
- "version": "2.20.1",
3
+ "version": "2.21.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",