@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.
- package/README.md +4 -0
- package/dist/index.js +167 -47
- 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:
|
|
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)
|
|
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).
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
blocks.push(
|
|
3231
|
-
|
|
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).
|
|
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
|
|
3606
|
-
return { content: [{
|
|
3723
|
+
const header = {
|
|
3607
3724
|
type: "text",
|
|
3608
3725
|
text: `# Attachments for ticket #${ticket_id} (${attachments.length} total)`
|
|
3609
|
-
}
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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",
|