@fruggr/zendesk-mcp-server 1.6.0 → 1.8.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 +38 -3
- package/dist/index.js +266 -79
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -190,7 +190,9 @@ No API key needed. Each user authenticates via their browser on the first tool c
|
|
|
190
190
|
1. Go to **Admin Center > Apps and integrations > APIs > OAuth Clients**
|
|
191
191
|
2. Create a public client:
|
|
192
192
|
- **Identifier**: `<your-subdomain>_zendesk` (or set `ZENDESK_OAUTH_CLIENT_ID`)
|
|
193
|
-
- **Redirect URL**: `http://localhost:
|
|
193
|
+
- **Redirect URL**: `http://localhost:27439/callback` (change the port to match
|
|
194
|
+
`ZENDESK_OAUTH_CALLBACK_PORT` / `--callback-port` if you override it; Zendesk
|
|
195
|
+
accepts several redirect URLs, one per line)
|
|
194
196
|
|
|
195
197
|
**Run:**
|
|
196
198
|
|
|
@@ -201,8 +203,22 @@ zendesk-mcp-server <your-subdomain>
|
|
|
201
203
|
On the first tool call, the server starts the sign-in flow: it opens a browser
|
|
202
204
|
window **and** returns a tool message containing the authorize URL. The call
|
|
203
205
|
does not block waiting for sign-in — authenticate in the browser (or open the
|
|
204
|
-
URL manually if it didn't open), then retry the request.
|
|
205
|
-
|
|
206
|
+
URL manually if it didn't open), then retry the request.
|
|
207
|
+
|
|
208
|
+
Once authenticated, the token is **persisted to disk** (one owner-only `0600`
|
|
209
|
+
file per subdomain in your OS config dir —
|
|
210
|
+
`%APPDATA%\fruggr\zendesk-mcp-server\<subdomain>.json` on Windows,
|
|
211
|
+
`${XDG_CONFIG_HOME:-~/.config}/fruggr/zendesk-mcp-server/<subdomain>.json`
|
|
212
|
+
elsewhere; override the path with `ZENDESK_TOKEN_FILE`). It is reused across restarts, so you don't
|
|
213
|
+
re-authenticate every time the MCP client respawns the server. If the Zendesk
|
|
214
|
+
OAuth client has token expiration enabled, the stored refresh token is used to
|
|
215
|
+
renew access silently; only an expired/invalid refresh token triggers a new
|
|
216
|
+
browser sign-in.
|
|
217
|
+
|
|
218
|
+
> **Port conflict?** If port `27439` is already in use the first tool call returns
|
|
219
|
+
> a clear error telling you to set `ZENDESK_OAUTH_CALLBACK_PORT` (or
|
|
220
|
+
> `--callback-port`) to a free port — remember to register the matching
|
|
221
|
+
> `http://localhost:<port>/callback` redirect URL in your Zendesk OAuth client.
|
|
206
222
|
|
|
207
223
|
### Option B: API token
|
|
208
224
|
|
|
@@ -290,6 +306,7 @@ Options:
|
|
|
290
306
|
--tool <name> Filter by tool name (repeatable, forces --mode all)
|
|
291
307
|
--read-only Only expose read operations
|
|
292
308
|
--log-level <level> debug | info (default) | warn | error
|
|
309
|
+
--callback-port <port> Local OAuth callback port (default 27439)
|
|
293
310
|
```
|
|
294
311
|
|
|
295
312
|
`--namespace` and `--read-only` are applied before the proxies are registered, so they narrow the surface in every mode — in the default `namespace` mode, `--namespace help_center` registers a single proxy (`zendesk_help_center`) instead of three.
|
|
@@ -313,6 +330,8 @@ zendesk-mcp-server acme --tool get_ticket --tool search_tickets --tool get_curre
|
|
|
313
330
|
|----------|----------|---------|-------------|
|
|
314
331
|
| `ZENDESK_SUBDOMAIN` | yes (or CLI arg) | — | Zendesk subdomain (e.g., `acme` for acme.zendesk.com) |
|
|
315
332
|
| `ZENDESK_OAUTH_CLIENT_ID` | no | `<subdomain>_zendesk` | OAuth client identifier |
|
|
333
|
+
| `ZENDESK_OAUTH_CALLBACK_PORT` | no | `27439` | Local port for the OAuth browser callback (also `--callback-port`). Must match the redirect URL registered in Zendesk. |
|
|
334
|
+
| `ZENDESK_TOKEN_FILE` | no | OS config dir | Path to the persisted OAuth token file (`0600`). |
|
|
316
335
|
| `ZENDESK_EMAIL` | for API token auth | — | Agent email for Basic auth |
|
|
317
336
|
| `ZENDESK_API_TOKEN` | for API token auth | — | Zendesk API token |
|
|
318
337
|
| `LOG_LEVEL` | no | `info` | Log verbosity (`debug` surfaces the full OAuth flow trace) |
|
|
@@ -340,6 +359,22 @@ When the browser fails to open, look for the `oauth_browser_open_failed` event:
|
|
|
340
359
|
it reports the underlying error, the platform, and which environment markers are
|
|
341
360
|
present (no secrets, tokens, or env values are ever logged).
|
|
342
361
|
|
|
362
|
+
### The OAuth callback port is already in use
|
|
363
|
+
|
|
364
|
+
The sign-in flow runs a short-lived local server on port `27439` to receive the
|
|
365
|
+
callback. If that port is taken, the first tool call fails with a message saying
|
|
366
|
+
so (and logs `oauth_callback_listen_failed`). Pick a free port with
|
|
367
|
+
`ZENDESK_OAUTH_CALLBACK_PORT=<port>` (or `--callback-port <port>`), and register
|
|
368
|
+
the matching `http://localhost:<port>/callback` redirect URL in your Zendesk
|
|
369
|
+
OAuth client.
|
|
370
|
+
|
|
371
|
+
### I have to re-authenticate every time
|
|
372
|
+
|
|
373
|
+
The OAuth token is persisted to an owner-only file in your OS config dir and
|
|
374
|
+
reused across restarts, so this shouldn't happen. If it does, check that the file
|
|
375
|
+
is writable (`ZENDESK_TOKEN_FILE` to relocate it) and look for
|
|
376
|
+
`token_persist_failed` in the logs.
|
|
377
|
+
|
|
343
378
|
Where each client writes the server's stderr:
|
|
344
379
|
|
|
345
380
|
| Client | Log location |
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
3
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
|
-
import { release } from "node:os";
|
|
5
|
+
import { homedir, release } from "node:os";
|
|
6
6
|
import open from "open";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
7
9
|
import * as z from "zod/v4";
|
|
8
10
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
11
|
import * as cheerio from "cheerio";
|
|
@@ -17,8 +19,6 @@ import remarkParse from "remark-parse";
|
|
|
17
19
|
import remarkRehype from "remark-rehype";
|
|
18
20
|
import remarkStringify from "remark-stringify";
|
|
19
21
|
import { unified } from "unified";
|
|
20
|
-
import { dirname, join } from "node:path";
|
|
21
|
-
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
23
23
|
//#region src/auth/api-token.ts
|
|
24
24
|
/**
|
|
@@ -126,7 +126,6 @@ const getOAuthUrls = (subdomain) => ({
|
|
|
126
126
|
});
|
|
127
127
|
//#endregion
|
|
128
128
|
//#region src/auth/browser-oauth.ts
|
|
129
|
-
const DEFAULT_CALLBACK_PORT = 3e3;
|
|
130
129
|
const AUTH_TIMEOUT_MS = 300 * 1e3;
|
|
131
130
|
/** Best-effort WSL detection: WSL kernels carry "microsoft" in /proc/version. */
|
|
132
131
|
const detectWsl = () => {
|
|
@@ -138,6 +137,16 @@ const detectWsl = () => {
|
|
|
138
137
|
}
|
|
139
138
|
};
|
|
140
139
|
/**
|
|
140
|
+
* Build an actionable error for a callback port that's already taken. The raw
|
|
141
|
+
* Node `EADDRINUSE` is opaque to both the user and the LLM; this spells out the
|
|
142
|
+
* fix (set a free port + register the matching redirect URL in Zendesk). The
|
|
143
|
+
* `(EADDRINUSE)` marker and `code` are kept for diagnostics/tests.
|
|
144
|
+
*/
|
|
145
|
+
const callbackPortInUseError = (port, cause) => Object.assign(/* @__PURE__ */ new Error(`Cannot start the Zendesk OAuth sign-in: local callback port ${port} is already in use by another process. Set ZENDESK_OAUTH_CALLBACK_PORT (or --callback-port) to a free port, then register http://localhost:<port>/callback as a redirect URL in your Zendesk OAuth client. (EADDRINUSE)`), {
|
|
146
|
+
code: "EADDRINUSE",
|
|
147
|
+
cause
|
|
148
|
+
});
|
|
149
|
+
/**
|
|
141
150
|
* Escape a string for safe interpolation into HTML text/attribute context.
|
|
142
151
|
* The local callback server echoes attacker-controllable values (the OAuth
|
|
143
152
|
* `error_description` query param, token-exchange error bodies) back into the
|
|
@@ -221,7 +230,7 @@ const startBrowserAuth = (config, logger = silentLogger) => {
|
|
|
221
230
|
const tokenData = await tokenResponse.json();
|
|
222
231
|
logger.info("oauth_authenticated");
|
|
223
232
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
224
|
-
res.end("<html><body><h1>Authentication successful!</h1><p>You can close this tab and return to Claude Code.</p><script>window.close()
|
|
233
|
+
res.end("<html><body><h1>Authentication successful!</h1><p>You can close this tab and return to Claude Code.</p><p>This tab will auto-close in <span id=\"t\">10</span>s.</p><script>let n=10;const el=document.getElementById(\"t\");const i=setInterval(()=>{n--;el.textContent=n;if(n<=0){clearInterval(i);window.close();}},1000);<\/script></body></html>");
|
|
225
234
|
clearTimeout(authTimeout);
|
|
226
235
|
callbackServer.close();
|
|
227
236
|
resolveToken(tokenData);
|
|
@@ -233,12 +242,18 @@ const startBrowserAuth = (config, logger = silentLogger) => {
|
|
|
233
242
|
rejectToken(err);
|
|
234
243
|
}
|
|
235
244
|
});
|
|
245
|
+
const requestedPort = config.callbackPort ?? 27439;
|
|
236
246
|
const onStartError = (err) => {
|
|
237
247
|
clearTimeout(authTimeout);
|
|
238
|
-
|
|
248
|
+
const code = err.code;
|
|
249
|
+
logger.error("oauth_callback_listen_failed", {
|
|
250
|
+
port: requestedPort,
|
|
251
|
+
errorCode: code
|
|
252
|
+
});
|
|
253
|
+
rejectStarted(code === "EADDRINUSE" ? callbackPortInUseError(requestedPort, err) : err);
|
|
239
254
|
};
|
|
240
255
|
callbackServer.once("error", onStartError);
|
|
241
|
-
callbackServer.listen(
|
|
256
|
+
callbackServer.listen(requestedPort, () => {
|
|
242
257
|
callbackServer.off("error", onStartError);
|
|
243
258
|
callbackServer.once("error", (err) => {
|
|
244
259
|
clearTimeout(authTimeout);
|
|
@@ -292,34 +307,199 @@ const startBrowserAuth = (config, logger = silentLogger) => {
|
|
|
292
307
|
});
|
|
293
308
|
});
|
|
294
309
|
};
|
|
310
|
+
/**
|
|
311
|
+
* Exchange a refresh token for a fresh access token (and a rotated refresh token)
|
|
312
|
+
* without any browser interaction. Public PKCE clients send no `client_secret`.
|
|
313
|
+
* Zendesk refresh tokens are single-use: the caller MUST persist the new
|
|
314
|
+
* `refresh_token` from the response. Throws on a non-2xx (expired/invalid
|
|
315
|
+
* refresh token) so the caller can fall back to the full browser flow.
|
|
316
|
+
*/
|
|
317
|
+
const refreshAccessToken = async (config, logger = silentLogger) => {
|
|
318
|
+
const { tokenUrl } = getOAuthUrls(config.subdomain);
|
|
319
|
+
const body = new URLSearchParams({
|
|
320
|
+
grant_type: "refresh_token",
|
|
321
|
+
refresh_token: config.refreshToken,
|
|
322
|
+
client_id: config.oauthClientId
|
|
323
|
+
});
|
|
324
|
+
const response = await fetch(tokenUrl, {
|
|
325
|
+
method: "POST",
|
|
326
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
327
|
+
body: body.toString()
|
|
328
|
+
});
|
|
329
|
+
logger.debug("oauth_token_refresh", { status: response.status });
|
|
330
|
+
if (!response.ok) {
|
|
331
|
+
const errorBody = await response.text();
|
|
332
|
+
throw new Error(`Token refresh failed (${response.status}): ${errorBody}`);
|
|
333
|
+
}
|
|
334
|
+
const tokenData = await response.json();
|
|
335
|
+
logger.info("oauth_token_refreshed");
|
|
336
|
+
return tokenData;
|
|
337
|
+
};
|
|
338
|
+
//#endregion
|
|
339
|
+
//#region src/utils/package-info.ts
|
|
340
|
+
const FALLBACK = {
|
|
341
|
+
name: "@fruggr/zendesk-mcp-server",
|
|
342
|
+
version: "0.0.0"
|
|
343
|
+
};
|
|
344
|
+
/**
|
|
345
|
+
* Read `name`/`version` from the package's own package.json at runtime instead
|
|
346
|
+
* of hardcoding them. Walks up from this module to the nearest package.json,
|
|
347
|
+
* which resolves correctly both when bundled (`dist/index.js` → repo root) and
|
|
348
|
+
* from source/tests (`src/` has no package.json, so the root is found). Reading
|
|
349
|
+
* at runtime (not inlining at build) matters because semantic-release bumps the
|
|
350
|
+
* version into package.json before publishing, after the build step.
|
|
351
|
+
*/
|
|
352
|
+
let cached;
|
|
353
|
+
const readPackageInfo = () => {
|
|
354
|
+
if (cached) return cached;
|
|
355
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
356
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
357
|
+
try {
|
|
358
|
+
const raw = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
359
|
+
if (raw && typeof raw === "object") {
|
|
360
|
+
const pkg = raw;
|
|
361
|
+
if (typeof pkg.name === "string" && typeof pkg.version === "string") {
|
|
362
|
+
cached = {
|
|
363
|
+
name: pkg.name,
|
|
364
|
+
version: pkg.version
|
|
365
|
+
};
|
|
366
|
+
return cached;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
} catch {}
|
|
370
|
+
const parent = dirname(dir);
|
|
371
|
+
if (parent === dir) break;
|
|
372
|
+
dir = parent;
|
|
373
|
+
}
|
|
374
|
+
cached = FALLBACK;
|
|
375
|
+
return cached;
|
|
376
|
+
};
|
|
377
|
+
//#endregion
|
|
378
|
+
//#region src/auth/token-persistence.ts
|
|
379
|
+
const isWindows = process.platform === "win32";
|
|
380
|
+
/**
|
|
381
|
+
* Config-dir segments derived from the *scoped* package name
|
|
382
|
+
* (`@fruggr/zendesk-mcp-server` → `fruggr` + `zendesk-mcp-server`) so the path is
|
|
383
|
+
* vendor-namespaced and can't collide with another `zendesk-mcp-server`.
|
|
384
|
+
*/
|
|
385
|
+
const appDirSegments = () => {
|
|
386
|
+
const { name } = readPackageInfo();
|
|
387
|
+
const scoped = /^@([^/]+)\/(.+)$/.exec(name);
|
|
388
|
+
return scoped?.[1] && scoped[2] ? [scoped[1], scoped[2]] : [name];
|
|
389
|
+
};
|
|
390
|
+
const configDir = () => {
|
|
391
|
+
const segments = appDirSegments();
|
|
392
|
+
if (isWindows) return join(process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming"), ...segments);
|
|
393
|
+
return join(process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"), ...segments);
|
|
394
|
+
};
|
|
395
|
+
const safeName = (subdomain) => subdomain.replace(/[^a-z0-9-]/gi, "_");
|
|
396
|
+
/**
|
|
397
|
+
* Path to the token file for a subdomain. Each subdomain gets its **own** file
|
|
398
|
+
* (`<subdomain>.json`, a single record) so concurrent processes for different
|
|
399
|
+
* subdomains never read-modify-write a shared file — no merge, no clobber.
|
|
400
|
+
* `ZENDESK_TOKEN_FILE` overrides with an explicit path (a single file; use the
|
|
401
|
+
* default layout for multi-subdomain installs).
|
|
402
|
+
*/
|
|
403
|
+
const resolveTokenPath = (subdomain) => {
|
|
404
|
+
const override = process.env["ZENDESK_TOKEN_FILE"];
|
|
405
|
+
if (override) return override;
|
|
406
|
+
return join(configDir(), `${safeName(subdomain)}.json`);
|
|
407
|
+
};
|
|
408
|
+
const writeFileAtomic = (path, record) => {
|
|
409
|
+
const dir = dirname(path);
|
|
410
|
+
mkdirSync(dir, { recursive: true });
|
|
411
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
412
|
+
writeFileSync(tmp, JSON.stringify(record, null, 2), "utf8");
|
|
413
|
+
if (!isWindows) chmodSync(tmp, 384);
|
|
414
|
+
renameSync(tmp, path);
|
|
415
|
+
if (!isWindows) try {
|
|
416
|
+
chmodSync(dir, 448);
|
|
417
|
+
} catch {}
|
|
418
|
+
};
|
|
419
|
+
const loadToken = (path) => {
|
|
420
|
+
try {
|
|
421
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
422
|
+
if (raw && typeof raw === "object" && typeof raw.accessToken === "string") return raw;
|
|
423
|
+
} catch {}
|
|
424
|
+
};
|
|
425
|
+
const saveToken = (path, record, logger = silentLogger) => {
|
|
426
|
+
try {
|
|
427
|
+
writeFileAtomic(path, record);
|
|
428
|
+
logger.debug("token_persisted");
|
|
429
|
+
} catch (err) {
|
|
430
|
+
logger.warn("token_persist_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
const clearToken = (path, logger = silentLogger) => {
|
|
434
|
+
try {
|
|
435
|
+
rmSync(path, { force: true });
|
|
436
|
+
logger.debug("token_cleared");
|
|
437
|
+
} catch (err) {
|
|
438
|
+
logger.warn("token_clear_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
439
|
+
}
|
|
440
|
+
};
|
|
295
441
|
//#endregion
|
|
296
442
|
//#region src/auth/token-store.ts
|
|
443
|
+
const EXPIRY_SKEW_MS = 6e4;
|
|
297
444
|
const createAuthRequiredError = (authorizeUrl) => Object.assign(/* @__PURE__ */ new Error("Zendesk authentication required. A browser window should have opened for you to sign in. If it did not, open this URL in your browser, then retry your request:\n" + authorizeUrl), {
|
|
298
445
|
name: "AuthRequiredError",
|
|
299
446
|
authorizeUrl
|
|
300
447
|
});
|
|
448
|
+
const expiryFrom = (expiresIn) => typeof expiresIn === "number" ? Date.now() + expiresIn * 1e3 : void 0;
|
|
301
449
|
const createTokenStore = (config, logger = silentLogger) => {
|
|
302
|
-
|
|
450
|
+
const tokenPath = resolveTokenPath(config.subdomain);
|
|
451
|
+
let token = loadToken(tokenPath);
|
|
452
|
+
if (token) logger.debug("oauth_token_loaded_from_disk");
|
|
303
453
|
let authorizeUrl;
|
|
304
454
|
let starting;
|
|
455
|
+
let refreshing;
|
|
456
|
+
const persist = (t) => saveToken(tokenPath, t, logger);
|
|
305
457
|
const setToken = (accessToken, refreshToken) => {
|
|
306
458
|
token = {
|
|
307
459
|
accessToken,
|
|
308
460
|
refreshToken
|
|
309
461
|
};
|
|
462
|
+
persist(token);
|
|
463
|
+
};
|
|
464
|
+
const isExpired = (t) => typeof t.expiresAt === "number" && Date.now() >= t.expiresAt - EXPIRY_SKEW_MS;
|
|
465
|
+
const tryRefresh = async (current) => {
|
|
466
|
+
if (!current.refreshToken) return void 0;
|
|
467
|
+
try {
|
|
468
|
+
const result = await refreshAccessToken({
|
|
469
|
+
subdomain: config.subdomain,
|
|
470
|
+
oauthClientId: config.oauthClientId,
|
|
471
|
+
refreshToken: current.refreshToken
|
|
472
|
+
}, logger);
|
|
473
|
+
token = {
|
|
474
|
+
accessToken: result.access_token,
|
|
475
|
+
refreshToken: result.refresh_token ?? current.refreshToken,
|
|
476
|
+
expiresAt: expiryFrom(result.expires_in)
|
|
477
|
+
};
|
|
478
|
+
persist(token);
|
|
479
|
+
logger.info("oauth_token_refreshed_cached");
|
|
480
|
+
return token.accessToken;
|
|
481
|
+
} catch (err) {
|
|
482
|
+
logger.warn("oauth_token_refresh_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
483
|
+
token = void 0;
|
|
484
|
+
clearToken(tokenPath, logger);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
310
487
|
};
|
|
311
488
|
const beginAuth = () => {
|
|
312
489
|
logger.info("oauth_auth_start");
|
|
313
490
|
return startBrowserAuth({
|
|
314
491
|
subdomain: config.subdomain,
|
|
315
|
-
oauthClientId: config.oauthClientId
|
|
492
|
+
oauthClientId: config.oauthClientId,
|
|
493
|
+
callbackPort: config.callbackPort
|
|
316
494
|
}, logger).then((started) => {
|
|
317
495
|
authorizeUrl = started.authorizeUrl;
|
|
318
496
|
started.tokenPromise.then((result) => {
|
|
319
497
|
token = {
|
|
320
498
|
accessToken: result.access_token,
|
|
321
|
-
refreshToken: result.refresh_token
|
|
499
|
+
refreshToken: result.refresh_token,
|
|
500
|
+
expiresAt: expiryFrom(result.expires_in)
|
|
322
501
|
};
|
|
502
|
+
persist(token);
|
|
323
503
|
logger.info("oauth_token_cached");
|
|
324
504
|
}).catch((err) => {
|
|
325
505
|
logger.warn("oauth_auth_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
@@ -334,16 +514,38 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
334
514
|
});
|
|
335
515
|
};
|
|
336
516
|
const getToken = async () => {
|
|
337
|
-
if (token) {
|
|
517
|
+
if (token && !isExpired(token)) {
|
|
338
518
|
logger.debug("oauth_token_cache_hit");
|
|
339
519
|
return token.accessToken;
|
|
340
520
|
}
|
|
521
|
+
if (token?.refreshToken) {
|
|
522
|
+
if (!refreshing) refreshing = tryRefresh(token).finally(() => {
|
|
523
|
+
refreshing = void 0;
|
|
524
|
+
});
|
|
525
|
+
const refreshed = await refreshing;
|
|
526
|
+
if (refreshed) return refreshed;
|
|
527
|
+
}
|
|
341
528
|
if (!starting) starting = beginAuth();
|
|
342
529
|
throw createAuthRequiredError(authorizeUrl ?? await starting);
|
|
343
530
|
};
|
|
531
|
+
const invalidate = () => {
|
|
532
|
+
if (token?.refreshToken) {
|
|
533
|
+
token = {
|
|
534
|
+
accessToken: token.accessToken,
|
|
535
|
+
refreshToken: token.refreshToken,
|
|
536
|
+
expiresAt: 0
|
|
537
|
+
};
|
|
538
|
+
persist(token);
|
|
539
|
+
} else {
|
|
540
|
+
token = void 0;
|
|
541
|
+
clearToken(tokenPath, logger);
|
|
542
|
+
}
|
|
543
|
+
logger.info("oauth_token_invalidated");
|
|
544
|
+
};
|
|
344
545
|
return {
|
|
345
546
|
getToken,
|
|
346
|
-
setToken
|
|
547
|
+
setToken,
|
|
548
|
+
invalidate
|
|
347
549
|
};
|
|
348
550
|
};
|
|
349
551
|
//#endregion
|
|
@@ -373,7 +575,8 @@ const ConfigSchema = z.object({
|
|
|
373
575
|
mode: ToolMode,
|
|
374
576
|
readOnly: z.boolean(),
|
|
375
577
|
namespaces: z.array(Namespace).optional(),
|
|
376
|
-
tools: z.array(z.string()).optional()
|
|
578
|
+
tools: z.array(z.string()).optional(),
|
|
579
|
+
callbackPort: z.number().int().min(1).max(65535).optional()
|
|
377
580
|
});
|
|
378
581
|
const parseCliArgs = (args) => {
|
|
379
582
|
const result = {};
|
|
@@ -397,6 +600,9 @@ const parseCliArgs = (args) => {
|
|
|
397
600
|
} else if (arg === "--log-level" && next) {
|
|
398
601
|
result.logLevel = next;
|
|
399
602
|
i++;
|
|
603
|
+
} else if (arg === "--callback-port" && next) {
|
|
604
|
+
result.callbackPort = Number(next);
|
|
605
|
+
i++;
|
|
400
606
|
} else if (!arg.startsWith("-") && positionalIndex === 0) {
|
|
401
607
|
result.subdomain = arg;
|
|
402
608
|
positionalIndex++;
|
|
@@ -409,6 +615,8 @@ const loadConfig = (argv = process.argv.slice(2)) => {
|
|
|
409
615
|
const subdomain = cli.subdomain ?? process.env["ZENDESK_SUBDOMAIN"] ?? "";
|
|
410
616
|
const oauthClientId = process.env["ZENDESK_OAUTH_CLIENT_ID"] ?? (subdomain ? `${subdomain}_zendesk` : "");
|
|
411
617
|
const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
|
|
618
|
+
const envCallbackPort = process.env["ZENDESK_OAUTH_CALLBACK_PORT"];
|
|
619
|
+
const callbackPort = cli.callbackPort ?? (envCallbackPort ? Number(envCallbackPort) : void 0);
|
|
412
620
|
return ConfigSchema.parse({
|
|
413
621
|
subdomain,
|
|
414
622
|
oauthClientId,
|
|
@@ -418,27 +626,11 @@ const loadConfig = (argv = process.argv.slice(2)) => {
|
|
|
418
626
|
mode,
|
|
419
627
|
readOnly: cli.readOnly ?? false,
|
|
420
628
|
namespaces: cli.namespaces,
|
|
421
|
-
tools: cli.tools
|
|
629
|
+
tools: cli.tools,
|
|
630
|
+
callbackPort
|
|
422
631
|
});
|
|
423
632
|
};
|
|
424
633
|
//#endregion
|
|
425
|
-
//#region src/routing/registry.ts
|
|
426
|
-
const filterTools = (allTools, options) => allTools.filter((tool) => {
|
|
427
|
-
if (options.readOnly && !tool.readOnly) return false;
|
|
428
|
-
if (options.namespaces?.length && !options.namespaces.includes(tool.namespace)) return false;
|
|
429
|
-
if (options.tools?.length && !options.tools.includes(tool.name)) return false;
|
|
430
|
-
return true;
|
|
431
|
-
});
|
|
432
|
-
const groupByNamespace = (tools) => {
|
|
433
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
434
|
-
for (const tool of tools) {
|
|
435
|
-
const existing = grouped.get(tool.namespace) ?? [];
|
|
436
|
-
existing.push(tool);
|
|
437
|
-
grouped.set(tool.namespace, existing);
|
|
438
|
-
}
|
|
439
|
-
return grouped;
|
|
440
|
-
};
|
|
441
|
-
//#endregion
|
|
442
634
|
//#region src/client/zendesk-api.ts
|
|
443
635
|
var ZendeskApiError = class ZendeskApiError extends Error {
|
|
444
636
|
status;
|
|
@@ -548,6 +740,23 @@ const helpCenterUpload = async (subdomain, token, path, formData) => {
|
|
|
548
740
|
return response.json();
|
|
549
741
|
};
|
|
550
742
|
//#endregion
|
|
743
|
+
//#region src/routing/registry.ts
|
|
744
|
+
const filterTools = (allTools, options) => allTools.filter((tool) => {
|
|
745
|
+
if (options.readOnly && !tool.readOnly) return false;
|
|
746
|
+
if (options.namespaces?.length && !options.namespaces.includes(tool.namespace)) return false;
|
|
747
|
+
if (options.tools?.length && !options.tools.includes(tool.name)) return false;
|
|
748
|
+
return true;
|
|
749
|
+
});
|
|
750
|
+
const groupByNamespace = (tools) => {
|
|
751
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
752
|
+
for (const tool of tools) {
|
|
753
|
+
const existing = grouped.get(tool.namespace) ?? [];
|
|
754
|
+
existing.push(tool);
|
|
755
|
+
grouped.set(tool.namespace, existing);
|
|
756
|
+
}
|
|
757
|
+
return grouped;
|
|
758
|
+
};
|
|
759
|
+
//#endregion
|
|
551
760
|
//#region src/utils/article-sections.ts
|
|
552
761
|
const HEADING_LEVELS = new Set([
|
|
553
762
|
"h1",
|
|
@@ -1981,46 +2190,21 @@ const createAllTools = (ctx) => [
|
|
|
1981
2190
|
...createUserTools(ctx)
|
|
1982
2191
|
];
|
|
1983
2192
|
//#endregion
|
|
1984
|
-
//#region src/
|
|
1985
|
-
const FALLBACK = {
|
|
1986
|
-
name: "@fruggr/zendesk-mcp-server",
|
|
1987
|
-
version: "0.0.0"
|
|
1988
|
-
};
|
|
2193
|
+
//#region src/server.ts
|
|
1989
2194
|
/**
|
|
1990
|
-
*
|
|
1991
|
-
*
|
|
1992
|
-
*
|
|
1993
|
-
*
|
|
1994
|
-
* at runtime (not inlining at build) matters because semantic-release bumps the
|
|
1995
|
-
* version into package.json before publishing, after the build step.
|
|
2195
|
+
* Invoke a tool handler, notifying `onUnauthorized` when Zendesk rejects the
|
|
2196
|
+
* token (401). This lets the OAuth store drop the dead token so the next call
|
|
2197
|
+
* refreshes/re-authenticates instead of replaying a revoked token. A no-op
|
|
2198
|
+
* callback (API-token mode) leaves behavior unchanged.
|
|
1996
2199
|
*/
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
const raw = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
2004
|
-
if (raw && typeof raw === "object") {
|
|
2005
|
-
const pkg = raw;
|
|
2006
|
-
if (typeof pkg.name === "string" && typeof pkg.version === "string") {
|
|
2007
|
-
cached = {
|
|
2008
|
-
name: pkg.name,
|
|
2009
|
-
version: pkg.version
|
|
2010
|
-
};
|
|
2011
|
-
return cached;
|
|
2012
|
-
}
|
|
2013
|
-
}
|
|
2014
|
-
} catch {}
|
|
2015
|
-
const parent = dirname(dir);
|
|
2016
|
-
if (parent === dir) break;
|
|
2017
|
-
dir = parent;
|
|
2200
|
+
const runHandler = async (def, params, onUnauthorized) => {
|
|
2201
|
+
try {
|
|
2202
|
+
return await def.handler(params);
|
|
2203
|
+
} catch (err) {
|
|
2204
|
+
if (onUnauthorized && err instanceof ZendeskApiError && err.status === 401) onUnauthorized();
|
|
2205
|
+
throw err;
|
|
2018
2206
|
}
|
|
2019
|
-
cached = FALLBACK;
|
|
2020
|
-
return cached;
|
|
2021
2207
|
};
|
|
2022
|
-
//#endregion
|
|
2023
|
-
//#region src/server.ts
|
|
2024
2208
|
const NAMESPACE_LABELS = {
|
|
2025
2209
|
tickets: {
|
|
2026
2210
|
toolName: "zendesk_tickets",
|
|
@@ -2047,7 +2231,7 @@ const aggregateAnnotations = (tools) => ({
|
|
|
2047
2231
|
idempotentHint: tools.every((t) => t.annotations.idempotentHint),
|
|
2048
2232
|
openWorldHint: true
|
|
2049
2233
|
});
|
|
2050
|
-
const registerProxyTool = (server, toolName, title, tools, handlerMap, readOnlyMode) => {
|
|
2234
|
+
const registerProxyTool = (server, toolName, title, tools, handlerMap, readOnlyMode, onUnauthorized) => {
|
|
2051
2235
|
const operationNames = tools.map((t) => t.name);
|
|
2052
2236
|
const operationList = buildOperationList(tools);
|
|
2053
2237
|
const annotations = aggregateAnnotations(tools);
|
|
@@ -2066,11 +2250,10 @@ const registerProxyTool = (server, toolName, title, tools, handlerMap, readOnlyM
|
|
|
2066
2250
|
type: "text",
|
|
2067
2251
|
text: `Unknown operation "${operation}". Available: ${operationNames.join(", ")}`
|
|
2068
2252
|
}] };
|
|
2069
|
-
|
|
2070
|
-
return def.handler(validated);
|
|
2253
|
+
return runHandler(def, def.inputSchema.parse(params), onUnauthorized);
|
|
2071
2254
|
});
|
|
2072
2255
|
};
|
|
2073
|
-
const createMcpServer = (config, getToken, logger = silentLogger) => {
|
|
2256
|
+
const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized) => {
|
|
2074
2257
|
const pkg = readPackageInfo();
|
|
2075
2258
|
const server = new McpServer({
|
|
2076
2259
|
name: pkg.name,
|
|
@@ -2094,18 +2277,18 @@ const createMcpServer = (config, getToken, logger = silentLogger) => {
|
|
|
2094
2277
|
description: tool.description,
|
|
2095
2278
|
inputSchema: tool.inputSchema,
|
|
2096
2279
|
annotations: tool.annotations
|
|
2097
|
-
}, async (params) => tool
|
|
2280
|
+
}, async (params) => runHandler(tool, params, onUnauthorized));
|
|
2098
2281
|
break;
|
|
2099
2282
|
case "namespace": {
|
|
2100
2283
|
const grouped = groupByNamespace(filteredTools);
|
|
2101
2284
|
for (const [namespace, tools] of grouped) {
|
|
2102
2285
|
const label = NAMESPACE_LABELS[namespace];
|
|
2103
|
-
if (label) registerProxyTool(server, label.toolName, label.title, tools, handlerMap, config.readOnly);
|
|
2286
|
+
if (label) registerProxyTool(server, label.toolName, label.title, tools, handlerMap, config.readOnly, onUnauthorized);
|
|
2104
2287
|
}
|
|
2105
2288
|
break;
|
|
2106
2289
|
}
|
|
2107
2290
|
case "single":
|
|
2108
|
-
registerProxyTool(server, "zendesk", "Zendesk", filteredTools, handlerMap, config.readOnly);
|
|
2291
|
+
registerProxyTool(server, "zendesk", "Zendesk", filteredTools, handlerMap, config.readOnly, onUnauthorized);
|
|
2109
2292
|
break;
|
|
2110
2293
|
}
|
|
2111
2294
|
logger.info("tools_registered", {
|
|
@@ -2130,10 +2313,14 @@ const main = async () => {
|
|
|
2130
2313
|
const staticToken = buildBasicAuthHeader(config.zendeskEmail, config.zendeskApiToken);
|
|
2131
2314
|
const getToken = () => staticToken;
|
|
2132
2315
|
await startStdioTransport(createMcpServer(config, getToken, logger), logger);
|
|
2133
|
-
} else
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2316
|
+
} else {
|
|
2317
|
+
const tokenStore = createTokenStore({
|
|
2318
|
+
subdomain: config.subdomain,
|
|
2319
|
+
oauthClientId: config.oauthClientId,
|
|
2320
|
+
callbackPort: config.callbackPort
|
|
2321
|
+
}, logger);
|
|
2322
|
+
await startStdioTransport(createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate), logger);
|
|
2323
|
+
}
|
|
2137
2324
|
};
|
|
2138
2325
|
main().catch((error) => {
|
|
2139
2326
|
console.error("Fatal error:", error);
|