@fruggr/zendesk-mcp-server 1.7.0 → 1.9.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 +250 -88
- package/dist/index.js +686 -97
- package/dist/index.js.map +1 -1
- package/glama.json +4 -0
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
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,7 @@ 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 {
|
|
21
|
-
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
22
23
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
23
24
|
//#region src/auth/api-token.ts
|
|
24
25
|
/**
|
|
@@ -126,7 +127,6 @@ const getOAuthUrls = (subdomain) => ({
|
|
|
126
127
|
});
|
|
127
128
|
//#endregion
|
|
128
129
|
//#region src/auth/browser-oauth.ts
|
|
129
|
-
const DEFAULT_CALLBACK_PORT = 3e3;
|
|
130
130
|
const AUTH_TIMEOUT_MS = 300 * 1e3;
|
|
131
131
|
/** Best-effort WSL detection: WSL kernels carry "microsoft" in /proc/version. */
|
|
132
132
|
const detectWsl = () => {
|
|
@@ -138,6 +138,16 @@ const detectWsl = () => {
|
|
|
138
138
|
}
|
|
139
139
|
};
|
|
140
140
|
/**
|
|
141
|
+
* Build an actionable error for a callback port that's already taken. The raw
|
|
142
|
+
* Node `EADDRINUSE` is opaque to both the user and the LLM; this spells out the
|
|
143
|
+
* fix (set a free port + register the matching redirect URL in Zendesk). The
|
|
144
|
+
* `(EADDRINUSE)` marker and `code` are kept for diagnostics/tests.
|
|
145
|
+
*/
|
|
146
|
+
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)`), {
|
|
147
|
+
code: "EADDRINUSE",
|
|
148
|
+
cause
|
|
149
|
+
});
|
|
150
|
+
/**
|
|
141
151
|
* Escape a string for safe interpolation into HTML text/attribute context.
|
|
142
152
|
* The local callback server echoes attacker-controllable values (the OAuth
|
|
143
153
|
* `error_description` query param, token-exchange error bodies) back into the
|
|
@@ -233,12 +243,18 @@ const startBrowserAuth = (config, logger = silentLogger) => {
|
|
|
233
243
|
rejectToken(err);
|
|
234
244
|
}
|
|
235
245
|
});
|
|
246
|
+
const requestedPort = config.callbackPort ?? 27439;
|
|
236
247
|
const onStartError = (err) => {
|
|
237
248
|
clearTimeout(authTimeout);
|
|
238
|
-
|
|
249
|
+
const code = err.code;
|
|
250
|
+
logger.error("oauth_callback_listen_failed", {
|
|
251
|
+
port: requestedPort,
|
|
252
|
+
errorCode: code
|
|
253
|
+
});
|
|
254
|
+
rejectStarted(code === "EADDRINUSE" ? callbackPortInUseError(requestedPort, err) : err);
|
|
239
255
|
};
|
|
240
256
|
callbackServer.once("error", onStartError);
|
|
241
|
-
callbackServer.listen(
|
|
257
|
+
callbackServer.listen(requestedPort, () => {
|
|
242
258
|
callbackServer.off("error", onStartError);
|
|
243
259
|
callbackServer.once("error", (err) => {
|
|
244
260
|
clearTimeout(authTimeout);
|
|
@@ -292,34 +308,199 @@ const startBrowserAuth = (config, logger = silentLogger) => {
|
|
|
292
308
|
});
|
|
293
309
|
});
|
|
294
310
|
};
|
|
311
|
+
/**
|
|
312
|
+
* Exchange a refresh token for a fresh access token (and a rotated refresh token)
|
|
313
|
+
* without any browser interaction. Public PKCE clients send no `client_secret`.
|
|
314
|
+
* Zendesk refresh tokens are single-use: the caller MUST persist the new
|
|
315
|
+
* `refresh_token` from the response. Throws on a non-2xx (expired/invalid
|
|
316
|
+
* refresh token) so the caller can fall back to the full browser flow.
|
|
317
|
+
*/
|
|
318
|
+
const refreshAccessToken = async (config, logger = silentLogger) => {
|
|
319
|
+
const { tokenUrl } = getOAuthUrls(config.subdomain);
|
|
320
|
+
const body = new URLSearchParams({
|
|
321
|
+
grant_type: "refresh_token",
|
|
322
|
+
refresh_token: config.refreshToken,
|
|
323
|
+
client_id: config.oauthClientId
|
|
324
|
+
});
|
|
325
|
+
const response = await fetch(tokenUrl, {
|
|
326
|
+
method: "POST",
|
|
327
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
328
|
+
body: body.toString()
|
|
329
|
+
});
|
|
330
|
+
logger.debug("oauth_token_refresh", { status: response.status });
|
|
331
|
+
if (!response.ok) {
|
|
332
|
+
const errorBody = await response.text();
|
|
333
|
+
throw new Error(`Token refresh failed (${response.status}): ${errorBody}`);
|
|
334
|
+
}
|
|
335
|
+
const tokenData = await response.json();
|
|
336
|
+
logger.info("oauth_token_refreshed");
|
|
337
|
+
return tokenData;
|
|
338
|
+
};
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/utils/package-info.ts
|
|
341
|
+
const FALLBACK = {
|
|
342
|
+
name: "@fruggr/zendesk-mcp-server",
|
|
343
|
+
version: "0.0.0"
|
|
344
|
+
};
|
|
345
|
+
/**
|
|
346
|
+
* Read `name`/`version` from the package's own package.json at runtime instead
|
|
347
|
+
* of hardcoding them. Walks up from this module to the nearest package.json,
|
|
348
|
+
* which resolves correctly both when bundled (`dist/index.js` → repo root) and
|
|
349
|
+
* from source/tests (`src/` has no package.json, so the root is found). Reading
|
|
350
|
+
* at runtime (not inlining at build) matters because semantic-release bumps the
|
|
351
|
+
* version into package.json before publishing, after the build step.
|
|
352
|
+
*/
|
|
353
|
+
let cached;
|
|
354
|
+
const readPackageInfo = () => {
|
|
355
|
+
if (cached) return cached;
|
|
356
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
357
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
358
|
+
try {
|
|
359
|
+
const raw = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
360
|
+
if (raw && typeof raw === "object") {
|
|
361
|
+
const pkg = raw;
|
|
362
|
+
if (typeof pkg.name === "string" && typeof pkg.version === "string") {
|
|
363
|
+
cached = {
|
|
364
|
+
name: pkg.name,
|
|
365
|
+
version: pkg.version
|
|
366
|
+
};
|
|
367
|
+
return cached;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
} catch {}
|
|
371
|
+
const parent = dirname(dir);
|
|
372
|
+
if (parent === dir) break;
|
|
373
|
+
dir = parent;
|
|
374
|
+
}
|
|
375
|
+
cached = FALLBACK;
|
|
376
|
+
return cached;
|
|
377
|
+
};
|
|
378
|
+
//#endregion
|
|
379
|
+
//#region src/auth/token-persistence.ts
|
|
380
|
+
const isWindows = process.platform === "win32";
|
|
381
|
+
/**
|
|
382
|
+
* Config-dir segments derived from the *scoped* package name
|
|
383
|
+
* (`@fruggr/zendesk-mcp-server` → `fruggr` + `zendesk-mcp-server`) so the path is
|
|
384
|
+
* vendor-namespaced and can't collide with another `zendesk-mcp-server`.
|
|
385
|
+
*/
|
|
386
|
+
const appDirSegments = () => {
|
|
387
|
+
const { name } = readPackageInfo();
|
|
388
|
+
const scoped = /^@([^/]+)\/(.+)$/.exec(name);
|
|
389
|
+
return scoped?.[1] && scoped[2] ? [scoped[1], scoped[2]] : [name];
|
|
390
|
+
};
|
|
391
|
+
const configDir = () => {
|
|
392
|
+
const segments = appDirSegments();
|
|
393
|
+
if (isWindows) return join(process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming"), ...segments);
|
|
394
|
+
return join(process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"), ...segments);
|
|
395
|
+
};
|
|
396
|
+
const safeName = (subdomain) => subdomain.replace(/[^a-z0-9-]/gi, "_");
|
|
397
|
+
/**
|
|
398
|
+
* Path to the token file for a subdomain. Each subdomain gets its **own** file
|
|
399
|
+
* (`<subdomain>.json`, a single record) so concurrent processes for different
|
|
400
|
+
* subdomains never read-modify-write a shared file — no merge, no clobber.
|
|
401
|
+
* `ZENDESK_TOKEN_FILE` overrides with an explicit path (a single file; use the
|
|
402
|
+
* default layout for multi-subdomain installs).
|
|
403
|
+
*/
|
|
404
|
+
const resolveTokenPath = (subdomain) => {
|
|
405
|
+
const override = process.env["ZENDESK_TOKEN_FILE"];
|
|
406
|
+
if (override) return override;
|
|
407
|
+
return join(configDir(), `${safeName(subdomain)}.json`);
|
|
408
|
+
};
|
|
409
|
+
const writeFileAtomic = (path, record) => {
|
|
410
|
+
const dir = dirname(path);
|
|
411
|
+
mkdirSync(dir, { recursive: true });
|
|
412
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
413
|
+
writeFileSync(tmp, JSON.stringify(record, null, 2), "utf8");
|
|
414
|
+
if (!isWindows) chmodSync(tmp, 384);
|
|
415
|
+
renameSync(tmp, path);
|
|
416
|
+
if (!isWindows) try {
|
|
417
|
+
chmodSync(dir, 448);
|
|
418
|
+
} catch {}
|
|
419
|
+
};
|
|
420
|
+
const loadToken = (path) => {
|
|
421
|
+
try {
|
|
422
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
423
|
+
if (raw && typeof raw === "object" && typeof raw.accessToken === "string") return raw;
|
|
424
|
+
} catch {}
|
|
425
|
+
};
|
|
426
|
+
const saveToken = (path, record, logger = silentLogger) => {
|
|
427
|
+
try {
|
|
428
|
+
writeFileAtomic(path, record);
|
|
429
|
+
logger.debug("token_persisted");
|
|
430
|
+
} catch (err) {
|
|
431
|
+
logger.warn("token_persist_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
const clearToken = (path, logger = silentLogger) => {
|
|
435
|
+
try {
|
|
436
|
+
rmSync(path, { force: true });
|
|
437
|
+
logger.debug("token_cleared");
|
|
438
|
+
} catch (err) {
|
|
439
|
+
logger.warn("token_clear_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
440
|
+
}
|
|
441
|
+
};
|
|
295
442
|
//#endregion
|
|
296
443
|
//#region src/auth/token-store.ts
|
|
444
|
+
const EXPIRY_SKEW_MS = 6e4;
|
|
297
445
|
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
446
|
name: "AuthRequiredError",
|
|
299
447
|
authorizeUrl
|
|
300
448
|
});
|
|
449
|
+
const expiryFrom = (expiresIn) => typeof expiresIn === "number" ? Date.now() + expiresIn * 1e3 : void 0;
|
|
301
450
|
const createTokenStore = (config, logger = silentLogger) => {
|
|
302
|
-
|
|
451
|
+
const tokenPath = resolveTokenPath(config.subdomain);
|
|
452
|
+
let token = loadToken(tokenPath);
|
|
453
|
+
if (token) logger.debug("oauth_token_loaded_from_disk");
|
|
303
454
|
let authorizeUrl;
|
|
304
455
|
let starting;
|
|
456
|
+
let refreshing;
|
|
457
|
+
const persist = (t) => saveToken(tokenPath, t, logger);
|
|
305
458
|
const setToken = (accessToken, refreshToken) => {
|
|
306
459
|
token = {
|
|
307
460
|
accessToken,
|
|
308
461
|
refreshToken
|
|
309
462
|
};
|
|
463
|
+
persist(token);
|
|
464
|
+
};
|
|
465
|
+
const isExpired = (t) => typeof t.expiresAt === "number" && Date.now() >= t.expiresAt - EXPIRY_SKEW_MS;
|
|
466
|
+
const tryRefresh = async (current) => {
|
|
467
|
+
if (!current.refreshToken) return void 0;
|
|
468
|
+
try {
|
|
469
|
+
const result = await refreshAccessToken({
|
|
470
|
+
subdomain: config.subdomain,
|
|
471
|
+
oauthClientId: config.oauthClientId,
|
|
472
|
+
refreshToken: current.refreshToken
|
|
473
|
+
}, logger);
|
|
474
|
+
token = {
|
|
475
|
+
accessToken: result.access_token,
|
|
476
|
+
refreshToken: result.refresh_token ?? current.refreshToken,
|
|
477
|
+
expiresAt: expiryFrom(result.expires_in)
|
|
478
|
+
};
|
|
479
|
+
persist(token);
|
|
480
|
+
logger.info("oauth_token_refreshed_cached");
|
|
481
|
+
return token.accessToken;
|
|
482
|
+
} catch (err) {
|
|
483
|
+
logger.warn("oauth_token_refresh_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
484
|
+
token = void 0;
|
|
485
|
+
clearToken(tokenPath, logger);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
310
488
|
};
|
|
311
489
|
const beginAuth = () => {
|
|
312
490
|
logger.info("oauth_auth_start");
|
|
313
491
|
return startBrowserAuth({
|
|
314
492
|
subdomain: config.subdomain,
|
|
315
|
-
oauthClientId: config.oauthClientId
|
|
493
|
+
oauthClientId: config.oauthClientId,
|
|
494
|
+
callbackPort: config.callbackPort
|
|
316
495
|
}, logger).then((started) => {
|
|
317
496
|
authorizeUrl = started.authorizeUrl;
|
|
318
497
|
started.tokenPromise.then((result) => {
|
|
319
498
|
token = {
|
|
320
499
|
accessToken: result.access_token,
|
|
321
|
-
refreshToken: result.refresh_token
|
|
500
|
+
refreshToken: result.refresh_token,
|
|
501
|
+
expiresAt: expiryFrom(result.expires_in)
|
|
322
502
|
};
|
|
503
|
+
persist(token);
|
|
323
504
|
logger.info("oauth_token_cached");
|
|
324
505
|
}).catch((err) => {
|
|
325
506
|
logger.warn("oauth_auth_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
@@ -334,16 +515,38 @@ const createTokenStore = (config, logger = silentLogger) => {
|
|
|
334
515
|
});
|
|
335
516
|
};
|
|
336
517
|
const getToken = async () => {
|
|
337
|
-
if (token) {
|
|
518
|
+
if (token && !isExpired(token)) {
|
|
338
519
|
logger.debug("oauth_token_cache_hit");
|
|
339
520
|
return token.accessToken;
|
|
340
521
|
}
|
|
522
|
+
if (token?.refreshToken) {
|
|
523
|
+
if (!refreshing) refreshing = tryRefresh(token).finally(() => {
|
|
524
|
+
refreshing = void 0;
|
|
525
|
+
});
|
|
526
|
+
const refreshed = await refreshing;
|
|
527
|
+
if (refreshed) return refreshed;
|
|
528
|
+
}
|
|
341
529
|
if (!starting) starting = beginAuth();
|
|
342
530
|
throw createAuthRequiredError(authorizeUrl ?? await starting);
|
|
343
531
|
};
|
|
532
|
+
const invalidate = () => {
|
|
533
|
+
if (token?.refreshToken) {
|
|
534
|
+
token = {
|
|
535
|
+
accessToken: token.accessToken,
|
|
536
|
+
refreshToken: token.refreshToken,
|
|
537
|
+
expiresAt: 0
|
|
538
|
+
};
|
|
539
|
+
persist(token);
|
|
540
|
+
} else {
|
|
541
|
+
token = void 0;
|
|
542
|
+
clearToken(tokenPath, logger);
|
|
543
|
+
}
|
|
544
|
+
logger.info("oauth_token_invalidated");
|
|
545
|
+
};
|
|
344
546
|
return {
|
|
345
547
|
getToken,
|
|
346
|
-
setToken
|
|
548
|
+
setToken,
|
|
549
|
+
invalidate
|
|
347
550
|
};
|
|
348
551
|
};
|
|
349
552
|
//#endregion
|
|
@@ -364,6 +567,7 @@ const Namespace = z.enum([
|
|
|
364
567
|
"help_center",
|
|
365
568
|
"users"
|
|
366
569
|
]);
|
|
570
|
+
const Transport = z.enum(["stdio", "http"]);
|
|
367
571
|
const ConfigSchema = z.object({
|
|
368
572
|
subdomain: z.string().min(1, "ZENDESK_SUBDOMAIN is required"),
|
|
369
573
|
oauthClientId: z.string().min(1),
|
|
@@ -373,8 +577,26 @@ const ConfigSchema = z.object({
|
|
|
373
577
|
mode: ToolMode,
|
|
374
578
|
readOnly: z.boolean(),
|
|
375
579
|
namespaces: z.array(Namespace).optional(),
|
|
376
|
-
tools: z.array(z.string()).optional()
|
|
580
|
+
tools: z.array(z.string()).optional(),
|
|
581
|
+
transport: Transport,
|
|
582
|
+
host: z.string().min(1),
|
|
583
|
+
port: z.number().int().min(0).max(65535),
|
|
584
|
+
publicUrl: z.string().url().optional(),
|
|
585
|
+
/**
|
|
586
|
+
* Additional browser origins allowed by CORS in HTTP mode. The default
|
|
587
|
+
* allowlist (the major web MCP clients + localhost-any-port for dev) is
|
|
588
|
+
* always applied; this list extends it. Native MCP clients (Claude
|
|
589
|
+
* Desktop, Claude Code CLI, Cursor, VS Code, Zed…) are unaffected
|
|
590
|
+
* because they send no Origin header.
|
|
591
|
+
*/
|
|
592
|
+
corsOrigins: z.array(z.string().url().transform((value) => new URL(value).origin).refine((origin) => origin !== "null", { message: "CORS origin must be an http(s) URL with a host" })).default([]),
|
|
593
|
+
callbackPort: z.number().int().min(1).max(65535).optional()
|
|
377
594
|
});
|
|
595
|
+
const parsePort = (raw, label) => {
|
|
596
|
+
if (!/^\d+$/.test(raw)) throw new Error(`Invalid ${label} value. Expected an integer 0-65535.`);
|
|
597
|
+
return Number(raw);
|
|
598
|
+
};
|
|
599
|
+
const parsePortEnv = (raw, label) => raw === void 0 || raw === "" ? void 0 : parsePort(raw, label);
|
|
378
600
|
const parseCliArgs = (args) => {
|
|
379
601
|
const result = {};
|
|
380
602
|
let positionalIndex = 0;
|
|
@@ -397,6 +619,25 @@ const parseCliArgs = (args) => {
|
|
|
397
619
|
} else if (arg === "--log-level" && next) {
|
|
398
620
|
result.logLevel = next;
|
|
399
621
|
i++;
|
|
622
|
+
} else if (arg === "--transport" && next) {
|
|
623
|
+
result.transport = next;
|
|
624
|
+
i++;
|
|
625
|
+
} else if (arg === "--host" && next) {
|
|
626
|
+
result.host = next;
|
|
627
|
+
i++;
|
|
628
|
+
} else if (arg === "--port" && next) {
|
|
629
|
+
result.port = parsePort(next, "--port");
|
|
630
|
+
i++;
|
|
631
|
+
} else if (arg === "--public-url" && next) {
|
|
632
|
+
result.publicUrl = next;
|
|
633
|
+
i++;
|
|
634
|
+
} else if (arg === "--cors-origin" && next) {
|
|
635
|
+
result.corsOrigins = result.corsOrigins ?? [];
|
|
636
|
+
result.corsOrigins.push(next);
|
|
637
|
+
i++;
|
|
638
|
+
} else if (arg === "--callback-port" && next) {
|
|
639
|
+
result.callbackPort = parsePort(next, "--callback-port");
|
|
640
|
+
i++;
|
|
400
641
|
} else if (!arg.startsWith("-") && positionalIndex === 0) {
|
|
401
642
|
result.subdomain = arg;
|
|
402
643
|
positionalIndex++;
|
|
@@ -409,36 +650,35 @@ const loadConfig = (argv = process.argv.slice(2)) => {
|
|
|
409
650
|
const subdomain = cli.subdomain ?? process.env["ZENDESK_SUBDOMAIN"] ?? "";
|
|
410
651
|
const oauthClientId = process.env["ZENDESK_OAUTH_CLIENT_ID"] ?? (subdomain ? `${subdomain}_zendesk` : "");
|
|
411
652
|
const mode = cli.tools?.length ? "all" : cli.mode ?? "namespace";
|
|
653
|
+
const transport = cli.transport ?? process.env["TRANSPORT"] ?? "stdio";
|
|
654
|
+
const host = cli.host ?? process.env["HOST"] ?? "0.0.0.0";
|
|
655
|
+
const port = cli.port ?? parsePortEnv(process.env["PORT"], "PORT") ?? 3e3;
|
|
656
|
+
const publicUrl = cli.publicUrl ?? process.env["PUBLIC_URL"];
|
|
657
|
+
const corsFromEnv = (process.env["CORS_ORIGIN"] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
658
|
+
const corsOrigins = [...cli.corsOrigins ?? [], ...corsFromEnv];
|
|
659
|
+
const callbackPort = cli.callbackPort ?? parsePortEnv(process.env["ZENDESK_OAUTH_CALLBACK_PORT"], "ZENDESK_OAUTH_CALLBACK_PORT");
|
|
660
|
+
const zendeskEmail = process.env["ZENDESK_EMAIL"];
|
|
661
|
+
const zendeskApiToken = process.env["ZENDESK_API_TOKEN"];
|
|
662
|
+
if (transport === "http" && zendeskEmail && zendeskApiToken) throw new Error("API token authentication (ZENDESK_EMAIL + ZENDESK_API_TOKEN) is not supported in HTTP mode. HTTP mode requires per-user OAuth 2.1 PKCE - unset these variables and configure your MCP client to perform the OAuth flow against Zendesk.");
|
|
412
663
|
return ConfigSchema.parse({
|
|
413
664
|
subdomain,
|
|
414
665
|
oauthClientId,
|
|
415
|
-
zendeskEmail
|
|
416
|
-
zendeskApiToken
|
|
666
|
+
zendeskEmail,
|
|
667
|
+
zendeskApiToken,
|
|
417
668
|
logLevel: cli.logLevel ?? process.env["LOG_LEVEL"] ?? "info",
|
|
418
669
|
mode,
|
|
419
670
|
readOnly: cli.readOnly ?? false,
|
|
420
671
|
namespaces: cli.namespaces,
|
|
421
|
-
tools: cli.tools
|
|
672
|
+
tools: cli.tools,
|
|
673
|
+
transport,
|
|
674
|
+
host,
|
|
675
|
+
port,
|
|
676
|
+
publicUrl,
|
|
677
|
+
corsOrigins,
|
|
678
|
+
callbackPort
|
|
422
679
|
});
|
|
423
680
|
};
|
|
424
681
|
//#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
682
|
//#region src/client/zendesk-api.ts
|
|
443
683
|
var ZendeskApiError = class ZendeskApiError extends Error {
|
|
444
684
|
status;
|
|
@@ -548,6 +788,23 @@ const helpCenterUpload = async (subdomain, token, path, formData) => {
|
|
|
548
788
|
return response.json();
|
|
549
789
|
};
|
|
550
790
|
//#endregion
|
|
791
|
+
//#region src/routing/registry.ts
|
|
792
|
+
const filterTools = (allTools, options) => allTools.filter((tool) => {
|
|
793
|
+
if (options.readOnly && !tool.readOnly) return false;
|
|
794
|
+
if (options.namespaces?.length && !options.namespaces.includes(tool.namespace)) return false;
|
|
795
|
+
if (options.tools?.length && !options.tools.includes(tool.name)) return false;
|
|
796
|
+
return true;
|
|
797
|
+
});
|
|
798
|
+
const groupByNamespace = (tools) => {
|
|
799
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
800
|
+
for (const tool of tools) {
|
|
801
|
+
const existing = grouped.get(tool.namespace) ?? [];
|
|
802
|
+
existing.push(tool);
|
|
803
|
+
grouped.set(tool.namespace, existing);
|
|
804
|
+
}
|
|
805
|
+
return grouped;
|
|
806
|
+
};
|
|
807
|
+
//#endregion
|
|
551
808
|
//#region src/utils/article-sections.ts
|
|
552
809
|
const HEADING_LEVELS = new Set([
|
|
553
810
|
"h1",
|
|
@@ -1981,46 +2238,21 @@ const createAllTools = (ctx) => [
|
|
|
1981
2238
|
...createUserTools(ctx)
|
|
1982
2239
|
];
|
|
1983
2240
|
//#endregion
|
|
1984
|
-
//#region src/
|
|
1985
|
-
const FALLBACK = {
|
|
1986
|
-
name: "@fruggr/zendesk-mcp-server",
|
|
1987
|
-
version: "0.0.0"
|
|
1988
|
-
};
|
|
2241
|
+
//#region src/server.ts
|
|
1989
2242
|
/**
|
|
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.
|
|
2243
|
+
* Invoke a tool handler, notifying `onUnauthorized` when Zendesk rejects the
|
|
2244
|
+
* token (401). This lets the OAuth store drop the dead token so the next call
|
|
2245
|
+
* refreshes/re-authenticates instead of replaying a revoked token. A no-op
|
|
2246
|
+
* callback (API-token mode) leaves behavior unchanged.
|
|
1996
2247
|
*/
|
|
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;
|
|
2248
|
+
const runHandler = async (def, params, onUnauthorized) => {
|
|
2249
|
+
try {
|
|
2250
|
+
return await def.handler(params);
|
|
2251
|
+
} catch (err) {
|
|
2252
|
+
if (onUnauthorized && err instanceof ZendeskApiError && err.status === 401) onUnauthorized();
|
|
2253
|
+
throw err;
|
|
2018
2254
|
}
|
|
2019
|
-
cached = FALLBACK;
|
|
2020
|
-
return cached;
|
|
2021
2255
|
};
|
|
2022
|
-
//#endregion
|
|
2023
|
-
//#region src/server.ts
|
|
2024
2256
|
const NAMESPACE_LABELS = {
|
|
2025
2257
|
tickets: {
|
|
2026
2258
|
toolName: "zendesk_tickets",
|
|
@@ -2047,30 +2279,36 @@ const aggregateAnnotations = (tools) => ({
|
|
|
2047
2279
|
idempotentHint: tools.every((t) => t.annotations.idempotentHint),
|
|
2048
2280
|
openWorldHint: true
|
|
2049
2281
|
});
|
|
2050
|
-
const
|
|
2282
|
+
const buildProxyDispatch = (tools, onUnauthorized) => {
|
|
2283
|
+
const operationNames = tools.map((t) => t.name);
|
|
2284
|
+
const localHandlers = new Map(tools.map((t) => [t.name, t]));
|
|
2285
|
+
return async (args) => {
|
|
2286
|
+
const { operation, params } = args;
|
|
2287
|
+
const def = localHandlers.get(operation);
|
|
2288
|
+
if (!def) return { content: [{
|
|
2289
|
+
type: "text",
|
|
2290
|
+
text: `Unknown operation "${operation}". Available: ${operationNames.join(", ")}`
|
|
2291
|
+
}] };
|
|
2292
|
+
return runHandler(def, def.inputSchema.parse(params), onUnauthorized);
|
|
2293
|
+
};
|
|
2294
|
+
};
|
|
2295
|
+
const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnauthorized) => {
|
|
2051
2296
|
const operationNames = tools.map((t) => t.name);
|
|
2052
2297
|
const operationList = buildOperationList(tools);
|
|
2053
2298
|
const annotations = aggregateAnnotations(tools);
|
|
2054
2299
|
const prefix = readOnlyMode ? "[RO] " : "";
|
|
2300
|
+
const dispatch = buildProxyDispatch(tools, onUnauthorized);
|
|
2055
2301
|
server.registerTool(toolName, {
|
|
2056
2302
|
title,
|
|
2057
2303
|
description: `${prefix}${title}. Specify the operation and its parameters.\n\nAvailable operations:\n${operationList}`,
|
|
2058
|
-
inputSchema:
|
|
2304
|
+
inputSchema: {
|
|
2059
2305
|
operation: z.string().describe(`One of: ${operationNames.join(", ")}`),
|
|
2060
2306
|
params: z.record(z.string(), z.unknown()).default({}).describe("Operation parameters")
|
|
2061
|
-
}
|
|
2307
|
+
},
|
|
2062
2308
|
annotations
|
|
2063
|
-
}, async (
|
|
2064
|
-
const def = handlerMap.get(operation);
|
|
2065
|
-
if (!def) return { content: [{
|
|
2066
|
-
type: "text",
|
|
2067
|
-
text: `Unknown operation "${operation}". Available: ${operationNames.join(", ")}`
|
|
2068
|
-
}] };
|
|
2069
|
-
const validated = def.inputSchema.parse(params);
|
|
2070
|
-
return def.handler(validated);
|
|
2071
|
-
});
|
|
2309
|
+
}, async (args) => dispatch(args));
|
|
2072
2310
|
};
|
|
2073
|
-
const createMcpServer = (config, getToken, logger = silentLogger) => {
|
|
2311
|
+
const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized) => {
|
|
2074
2312
|
const pkg = readPackageInfo();
|
|
2075
2313
|
const server = new McpServer({
|
|
2076
2314
|
name: pkg.name,
|
|
@@ -2085,27 +2323,25 @@ const createMcpServer = (config, getToken, logger = silentLogger) => {
|
|
|
2085
2323
|
namespaces: config.namespaces,
|
|
2086
2324
|
tools: config.tools
|
|
2087
2325
|
});
|
|
2088
|
-
const handlerMap = /* @__PURE__ */ new Map();
|
|
2089
|
-
for (const tool of filteredTools) handlerMap.set(tool.name, tool);
|
|
2090
2326
|
switch (config.mode) {
|
|
2091
2327
|
case "all":
|
|
2092
2328
|
for (const tool of filteredTools) server.registerTool(tool.name, {
|
|
2093
2329
|
title: tool.title,
|
|
2094
2330
|
description: tool.description,
|
|
2095
|
-
inputSchema: tool.inputSchema,
|
|
2331
|
+
inputSchema: tool.inputSchema.shape,
|
|
2096
2332
|
annotations: tool.annotations
|
|
2097
|
-
}, async (params) => tool
|
|
2333
|
+
}, async (params) => runHandler(tool, params, onUnauthorized));
|
|
2098
2334
|
break;
|
|
2099
2335
|
case "namespace": {
|
|
2100
2336
|
const grouped = groupByNamespace(filteredTools);
|
|
2101
2337
|
for (const [namespace, tools] of grouped) {
|
|
2102
2338
|
const label = NAMESPACE_LABELS[namespace];
|
|
2103
|
-
if (label) registerProxyTool(server, label.toolName, label.title, tools,
|
|
2339
|
+
if (label) registerProxyTool(server, label.toolName, label.title, tools, config.readOnly, onUnauthorized);
|
|
2104
2340
|
}
|
|
2105
2341
|
break;
|
|
2106
2342
|
}
|
|
2107
2343
|
case "single":
|
|
2108
|
-
registerProxyTool(server, "zendesk", "Zendesk", filteredTools,
|
|
2344
|
+
registerProxyTool(server, "zendesk", "Zendesk", filteredTools, config.readOnly, onUnauthorized);
|
|
2109
2345
|
break;
|
|
2110
2346
|
}
|
|
2111
2347
|
logger.info("tools_registered", {
|
|
@@ -2115,6 +2351,350 @@ const createMcpServer = (config, getToken, logger = silentLogger) => {
|
|
|
2115
2351
|
return server;
|
|
2116
2352
|
};
|
|
2117
2353
|
//#endregion
|
|
2354
|
+
//#region src/transports/http.ts
|
|
2355
|
+
const WILDCARD_HOSTS = new Set([
|
|
2356
|
+
"0.0.0.0",
|
|
2357
|
+
"::",
|
|
2358
|
+
"*"
|
|
2359
|
+
]);
|
|
2360
|
+
const DEFAULT_BROWSER_MCP_CLIENT_ORIGINS = [
|
|
2361
|
+
"https://chatgpt.com",
|
|
2362
|
+
"https://chat.openai.com",
|
|
2363
|
+
"https://claude.ai",
|
|
2364
|
+
"https://gemini.google.com",
|
|
2365
|
+
"https://copilot.microsoft.com",
|
|
2366
|
+
"https://www.perplexity.ai",
|
|
2367
|
+
"https://chat.mistral.ai",
|
|
2368
|
+
"https://grok.com"
|
|
2369
|
+
];
|
|
2370
|
+
const CORS_ALLOWED_METHODS = "GET, POST, DELETE, OPTIONS";
|
|
2371
|
+
const CORS_ALLOWED_HEADERS = "Authorization, Content-Type, Accept, mcp-session-id, mcp-protocol-version, last-event-id";
|
|
2372
|
+
const CORS_EXPOSE_HEADERS = "mcp-session-id";
|
|
2373
|
+
const CORS_MAX_AGE = "600";
|
|
2374
|
+
const LOCALHOST_HOSTNAMES = new Set([
|
|
2375
|
+
"localhost",
|
|
2376
|
+
"127.0.0.1",
|
|
2377
|
+
"[::1]"
|
|
2378
|
+
]);
|
|
2379
|
+
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
|
|
2380
|
+
/**
|
|
2381
|
+
* Returns the origin string to reflect in `Access-Control-Allow-Origin`, or
|
|
2382
|
+
* `undefined` if the origin is not allowed.
|
|
2383
|
+
*
|
|
2384
|
+
* The returned value is **never the raw `Origin` request header**. It comes
|
|
2385
|
+
* from one of three sanitization points:
|
|
2386
|
+
*
|
|
2387
|
+
* 1. An entry of the hardcoded `DEFAULT_BROWSER_MCP_CLIENT_ORIGINS` array.
|
|
2388
|
+
* 2. An entry of the operator-configured `extraOrigins` array.
|
|
2389
|
+
* 3. A loopback origin rebuilt from validated URL components after the
|
|
2390
|
+
* hostname has been allowlisted against `LOCALHOST_HOSTNAMES`.
|
|
2391
|
+
*
|
|
2392
|
+
* This shape keeps the dataflow from request header to response header
|
|
2393
|
+
* gated by a constant allowlist, which is the pattern CodeQL's
|
|
2394
|
+
* `js/cors-misconfiguration-for-credentials` rule recognises as safe when
|
|
2395
|
+
* combined with `Access-Control-Allow-Credentials: true`.
|
|
2396
|
+
*/
|
|
2397
|
+
const resolveAllowedOrigin = (origin, extraOrigins) => {
|
|
2398
|
+
const defaultMatch = DEFAULT_BROWSER_MCP_CLIENT_ORIGINS.find((entry) => entry === origin);
|
|
2399
|
+
if (defaultMatch) return defaultMatch;
|
|
2400
|
+
const extraMatch = extraOrigins?.find((entry) => entry === origin);
|
|
2401
|
+
if (extraMatch) return extraMatch;
|
|
2402
|
+
try {
|
|
2403
|
+
const url = new URL(origin);
|
|
2404
|
+
if (!ALLOWED_PROTOCOLS.has(url.protocol)) return void 0;
|
|
2405
|
+
if (!LOCALHOST_HOSTNAMES.has(url.hostname)) return void 0;
|
|
2406
|
+
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
|
2407
|
+
return `${url.protocol}//${url.hostname}:${port}`;
|
|
2408
|
+
} catch {
|
|
2409
|
+
return;
|
|
2410
|
+
}
|
|
2411
|
+
};
|
|
2412
|
+
const applyCorsHeaders = (req, res, extraOrigins) => {
|
|
2413
|
+
const requestOrigin = req.headers["origin"];
|
|
2414
|
+
if (typeof requestOrigin !== "string" || requestOrigin.length === 0) return;
|
|
2415
|
+
const allowedOrigin = resolveAllowedOrigin(requestOrigin, extraOrigins);
|
|
2416
|
+
if (!allowedOrigin) return;
|
|
2417
|
+
res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
|
|
2418
|
+
res.setHeader("Vary", "Origin");
|
|
2419
|
+
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
2420
|
+
res.setHeader("Access-Control-Expose-Headers", CORS_EXPOSE_HEADERS);
|
|
2421
|
+
};
|
|
2422
|
+
const handleCorsPreflight = (req, res, extraOrigins) => {
|
|
2423
|
+
if (req.method !== "OPTIONS") return false;
|
|
2424
|
+
applyCorsHeaders(req, res, extraOrigins);
|
|
2425
|
+
if (res.getHeader("Access-Control-Allow-Origin")) {
|
|
2426
|
+
res.setHeader("Access-Control-Allow-Methods", CORS_ALLOWED_METHODS);
|
|
2427
|
+
res.setHeader("Access-Control-Allow-Headers", CORS_ALLOWED_HEADERS);
|
|
2428
|
+
res.setHeader("Access-Control-Max-Age", CORS_MAX_AGE);
|
|
2429
|
+
}
|
|
2430
|
+
res.writeHead(204);
|
|
2431
|
+
res.end();
|
|
2432
|
+
return true;
|
|
2433
|
+
};
|
|
2434
|
+
const resolveResourceUrl = (config, logger = silentLogger) => {
|
|
2435
|
+
if (config.publicUrl) return config.publicUrl.replace(/\/+$/, "");
|
|
2436
|
+
if (!WILDCARD_HOSTS.has(config.host)) return `http://${config.host}:${config.port}`;
|
|
2437
|
+
logger.warn("public_url_unset", {
|
|
2438
|
+
host: config.host,
|
|
2439
|
+
advertised: `http://${config.host}:${config.port}`,
|
|
2440
|
+
hint: "OAuth discovery will advertise a non-routable resource identifier and spec-compliant MCP clients may refuse the connection. Set PUBLIC_URL (or --public-url) to the URL clients use to reach this server (e.g. https://your-host.example.com)."
|
|
2441
|
+
});
|
|
2442
|
+
return `http://${config.host}:${config.port}`;
|
|
2443
|
+
};
|
|
2444
|
+
const MISSING_BEARER_MESSAGE = "Missing Authorization: Bearer <zendesk-oauth-token> header. HTTP mode requires per-user OAuth 2.1 PKCE - obtain a token from Zendesk via your MCP client.";
|
|
2445
|
+
const extractBearer = (request) => {
|
|
2446
|
+
const header = request.headers["authorization"];
|
|
2447
|
+
if (typeof header !== "string") return void 0;
|
|
2448
|
+
if (!header.toLowerCase().startsWith("bearer ")) return void 0;
|
|
2449
|
+
return header.slice(7).trim();
|
|
2450
|
+
};
|
|
2451
|
+
const buildOAuthMetadata = (config, logger = silentLogger) => {
|
|
2452
|
+
const { authorizeUrl, tokenUrl } = getOAuthUrls(config.subdomain);
|
|
2453
|
+
const issuer = `https://${config.subdomain}.zendesk.com`;
|
|
2454
|
+
const resource = resolveResourceUrl(config, logger);
|
|
2455
|
+
return {
|
|
2456
|
+
protectedResource: {
|
|
2457
|
+
authorization_servers: [issuer],
|
|
2458
|
+
resource,
|
|
2459
|
+
bearer_methods_supported: ["header"],
|
|
2460
|
+
scopes_supported: ["read", "write"]
|
|
2461
|
+
},
|
|
2462
|
+
authorizationServer: {
|
|
2463
|
+
issuer,
|
|
2464
|
+
authorization_endpoint: authorizeUrl,
|
|
2465
|
+
token_endpoint: tokenUrl,
|
|
2466
|
+
response_types_supported: ["code"],
|
|
2467
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
2468
|
+
code_challenge_methods_supported: ["S256"],
|
|
2469
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
2470
|
+
scopes_supported: ["read", "write"]
|
|
2471
|
+
}
|
|
2472
|
+
};
|
|
2473
|
+
};
|
|
2474
|
+
const sendJson = (res, status, body) => {
|
|
2475
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
2476
|
+
res.end(JSON.stringify(body));
|
|
2477
|
+
};
|
|
2478
|
+
const sendJsonRpcError = (res, status, code, message, headers = {}) => {
|
|
2479
|
+
res.writeHead(status, {
|
|
2480
|
+
"Content-Type": "application/json",
|
|
2481
|
+
...headers
|
|
2482
|
+
});
|
|
2483
|
+
res.end(JSON.stringify({
|
|
2484
|
+
error: {
|
|
2485
|
+
code,
|
|
2486
|
+
message
|
|
2487
|
+
},
|
|
2488
|
+
id: null,
|
|
2489
|
+
jsonrpc: "2.0"
|
|
2490
|
+
}));
|
|
2491
|
+
};
|
|
2492
|
+
const sendUnauthorized = (res, resource) => {
|
|
2493
|
+
sendJsonRpcError(res, 401, -32e3, MISSING_BEARER_MESSAGE, { "WWW-Authenticate": `Bearer resource_metadata="${resource}/.well-known/oauth-protected-resource", error="invalid_token", error_description="${MISSING_BEARER_MESSAGE}"` });
|
|
2494
|
+
};
|
|
2495
|
+
const readJsonBody = (req, maxBodyBytes) => new Promise((resolve) => {
|
|
2496
|
+
const chunks = [];
|
|
2497
|
+
let total = 0;
|
|
2498
|
+
let settled = false;
|
|
2499
|
+
const settle = (result) => {
|
|
2500
|
+
if (settled) return;
|
|
2501
|
+
settled = true;
|
|
2502
|
+
resolve(result);
|
|
2503
|
+
};
|
|
2504
|
+
req.on("data", (chunk) => {
|
|
2505
|
+
total += chunk.length;
|
|
2506
|
+
if (total > maxBodyBytes) {
|
|
2507
|
+
req.removeAllListeners("data");
|
|
2508
|
+
settle({
|
|
2509
|
+
ok: false,
|
|
2510
|
+
status: 413,
|
|
2511
|
+
rpcCode: -32600,
|
|
2512
|
+
message: `Request body exceeds ${maxBodyBytes} bytes.`
|
|
2513
|
+
});
|
|
2514
|
+
return;
|
|
2515
|
+
}
|
|
2516
|
+
chunks.push(chunk);
|
|
2517
|
+
});
|
|
2518
|
+
req.on("end", () => {
|
|
2519
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
2520
|
+
if (!raw) {
|
|
2521
|
+
settle({
|
|
2522
|
+
ok: true,
|
|
2523
|
+
value: void 0
|
|
2524
|
+
});
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
try {
|
|
2528
|
+
settle({
|
|
2529
|
+
ok: true,
|
|
2530
|
+
value: JSON.parse(raw)
|
|
2531
|
+
});
|
|
2532
|
+
} catch {
|
|
2533
|
+
settle({
|
|
2534
|
+
ok: false,
|
|
2535
|
+
status: 400,
|
|
2536
|
+
rpcCode: -32700,
|
|
2537
|
+
message: "Parse error: request body is not valid JSON."
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
2540
|
+
});
|
|
2541
|
+
req.on("error", () => settle({
|
|
2542
|
+
ok: false,
|
|
2543
|
+
status: 400,
|
|
2544
|
+
rpcCode: -32600,
|
|
2545
|
+
message: "Request body could not be read."
|
|
2546
|
+
}));
|
|
2547
|
+
});
|
|
2548
|
+
const respondBodyError = (req, res, failure) => {
|
|
2549
|
+
const headers = failure.status === 413 ? { Connection: "close" } : {};
|
|
2550
|
+
sendJsonRpcError(res, failure.status, failure.rpcCode, failure.message, headers);
|
|
2551
|
+
if (failure.status === 413) if (res.writableFinished) req.destroy();
|
|
2552
|
+
else res.once("finish", () => req.destroy());
|
|
2553
|
+
};
|
|
2554
|
+
const SESSION_IDLE_TIMEOUT_MS = 1800 * 1e3;
|
|
2555
|
+
const SESSION_SWEEP_INTERVAL_MS = 60 * 1e3;
|
|
2556
|
+
const startHttpTransport = async (config, logger = silentLogger, options = {}) => {
|
|
2557
|
+
const metadata = buildOAuthMetadata(config, logger);
|
|
2558
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
2559
|
+
const idleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS;
|
|
2560
|
+
const maxBodyBytes = options.maxBodyBytes ?? 4194304;
|
|
2561
|
+
const handleMcpRequest = async (req, res) => {
|
|
2562
|
+
const bearer = extractBearer(req);
|
|
2563
|
+
if (!bearer) {
|
|
2564
|
+
sendUnauthorized(res, metadata.protectedResource.resource);
|
|
2565
|
+
return;
|
|
2566
|
+
}
|
|
2567
|
+
const sessionId = typeof req.headers["mcp-session-id"] === "string" ? req.headers["mcp-session-id"] : void 0;
|
|
2568
|
+
if (sessionId) {
|
|
2569
|
+
const session = sessions.get(sessionId);
|
|
2570
|
+
if (session) {
|
|
2571
|
+
session.auth.bearer = bearer;
|
|
2572
|
+
session.lastActivityAt = Date.now();
|
|
2573
|
+
const body = req.method === "POST" ? await readJsonBody(req, maxBodyBytes) : {
|
|
2574
|
+
ok: true,
|
|
2575
|
+
value: void 0
|
|
2576
|
+
};
|
|
2577
|
+
if (!body.ok) {
|
|
2578
|
+
respondBodyError(req, res, body);
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2581
|
+
await session.transport.handleRequest(req, res, body.value);
|
|
2582
|
+
return;
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
if (req.method !== "POST") {
|
|
2586
|
+
sendJsonRpcError(res, 400, -32e3, "No active session; initialize via POST first.");
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
const body = await readJsonBody(req, maxBodyBytes);
|
|
2590
|
+
if (!body.ok) {
|
|
2591
|
+
respondBodyError(req, res, body);
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
const auth = { bearer };
|
|
2595
|
+
const server = createMcpServer(config, () => auth.bearer, logger);
|
|
2596
|
+
const transport = new StreamableHTTPServerTransport({
|
|
2597
|
+
sessionIdGenerator: () => randomUUID(),
|
|
2598
|
+
onsessioninitialized: (newId) => {
|
|
2599
|
+
sessions.set(newId, {
|
|
2600
|
+
transport,
|
|
2601
|
+
auth,
|
|
2602
|
+
lastActivityAt: Date.now(),
|
|
2603
|
+
close: async () => {
|
|
2604
|
+
await transport.close();
|
|
2605
|
+
await server.close();
|
|
2606
|
+
}
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
});
|
|
2610
|
+
transport.onclose = () => {
|
|
2611
|
+
if (transport.sessionId) sessions.delete(transport.sessionId);
|
|
2612
|
+
};
|
|
2613
|
+
await server.connect(transport);
|
|
2614
|
+
await transport.handleRequest(req, res, body.value);
|
|
2615
|
+
};
|
|
2616
|
+
const requestListener = async (req, res) => {
|
|
2617
|
+
try {
|
|
2618
|
+
if (handleCorsPreflight(req, res, config.corsOrigins)) return;
|
|
2619
|
+
applyCorsHeaders(req, res, config.corsOrigins);
|
|
2620
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
2621
|
+
if (url.pathname === "/.well-known/oauth-protected-resource" && req.method === "GET") {
|
|
2622
|
+
sendJson(res, 200, metadata.protectedResource);
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
2625
|
+
if (url.pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
|
|
2626
|
+
sendJson(res, 200, metadata.authorizationServer);
|
|
2627
|
+
return;
|
|
2628
|
+
}
|
|
2629
|
+
if (url.pathname === "/healthz" && req.method === "GET") {
|
|
2630
|
+
sendJson(res, 200, {
|
|
2631
|
+
status: "ok",
|
|
2632
|
+
subdomain: config.subdomain
|
|
2633
|
+
});
|
|
2634
|
+
return;
|
|
2635
|
+
}
|
|
2636
|
+
if (url.pathname === "/mcp") {
|
|
2637
|
+
await handleMcpRequest(req, res);
|
|
2638
|
+
return;
|
|
2639
|
+
}
|
|
2640
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2641
|
+
res.end(JSON.stringify({
|
|
2642
|
+
error: "Not found",
|
|
2643
|
+
path: url.pathname
|
|
2644
|
+
}));
|
|
2645
|
+
} catch (err) {
|
|
2646
|
+
const message = err instanceof Error ? err.message : "Internal Server Error";
|
|
2647
|
+
if (!res.headersSent) sendJsonRpcError(res, 500, -32603, message);
|
|
2648
|
+
else if (!res.writableEnded) res.end();
|
|
2649
|
+
}
|
|
2650
|
+
};
|
|
2651
|
+
const httpServer = createServer((req, res) => {
|
|
2652
|
+
requestListener(req, res);
|
|
2653
|
+
});
|
|
2654
|
+
await new Promise((resolve, reject) => {
|
|
2655
|
+
httpServer.once("error", reject);
|
|
2656
|
+
httpServer.listen(config.port, config.host, () => {
|
|
2657
|
+
httpServer.off("error", reject);
|
|
2658
|
+
resolve();
|
|
2659
|
+
});
|
|
2660
|
+
});
|
|
2661
|
+
const addr = httpServer.address();
|
|
2662
|
+
const boundPort = typeof addr === "object" && addr !== null ? addr.port : config.port;
|
|
2663
|
+
logger.info("http_transport_ready", {
|
|
2664
|
+
host: config.host,
|
|
2665
|
+
port: boundPort
|
|
2666
|
+
});
|
|
2667
|
+
const sweepIdleSessions = async () => {
|
|
2668
|
+
const cutoff = Date.now() - idleTimeoutMs;
|
|
2669
|
+
for (const [id, session] of sessions) {
|
|
2670
|
+
if (session.lastActivityAt > cutoff) continue;
|
|
2671
|
+
sessions.delete(id);
|
|
2672
|
+
try {
|
|
2673
|
+
await session.close();
|
|
2674
|
+
} catch (err) {
|
|
2675
|
+
logger.warn("session_close_failed", {
|
|
2676
|
+
sessionId: id,
|
|
2677
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
};
|
|
2682
|
+
const sweeper = setInterval(() => void sweepIdleSessions(), options.sweepIntervalMs ?? SESSION_SWEEP_INTERVAL_MS);
|
|
2683
|
+
sweeper.unref();
|
|
2684
|
+
return {
|
|
2685
|
+
port: boundPort,
|
|
2686
|
+
close: async () => {
|
|
2687
|
+
clearInterval(sweeper);
|
|
2688
|
+
await Promise.all([...sessions.values()].map((session) => session.close()));
|
|
2689
|
+
sessions.clear();
|
|
2690
|
+
httpServer.closeAllConnections();
|
|
2691
|
+
await new Promise((resolve, reject) => {
|
|
2692
|
+
httpServer.close((err) => err ? reject(err) : resolve());
|
|
2693
|
+
});
|
|
2694
|
+
}
|
|
2695
|
+
};
|
|
2696
|
+
};
|
|
2697
|
+
//#endregion
|
|
2118
2698
|
//#region src/transports/stdio.ts
|
|
2119
2699
|
const startStdioTransport = async (server, logger = silentLogger) => {
|
|
2120
2700
|
const transport = new StdioServerTransport();
|
|
@@ -2123,17 +2703,26 @@ const startStdioTransport = async (server, logger = silentLogger) => {
|
|
|
2123
2703
|
};
|
|
2124
2704
|
//#endregion
|
|
2125
2705
|
//#region src/index.ts
|
|
2126
|
-
const
|
|
2127
|
-
const config = loadConfig();
|
|
2128
|
-
const logger = createLogger(config.logLevel);
|
|
2706
|
+
const buildStdioServer = (config, logger) => {
|
|
2129
2707
|
if (config.zendeskEmail && config.zendeskApiToken) {
|
|
2130
2708
|
const staticToken = buildBasicAuthHeader(config.zendeskEmail, config.zendeskApiToken);
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2709
|
+
return createMcpServer(config, () => staticToken, logger);
|
|
2710
|
+
}
|
|
2711
|
+
const tokenStore = createTokenStore({
|
|
2134
2712
|
subdomain: config.subdomain,
|
|
2135
|
-
oauthClientId: config.oauthClientId
|
|
2136
|
-
|
|
2713
|
+
oauthClientId: config.oauthClientId,
|
|
2714
|
+
callbackPort: config.callbackPort
|
|
2715
|
+
}, logger);
|
|
2716
|
+
return createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
|
|
2717
|
+
};
|
|
2718
|
+
const main = async () => {
|
|
2719
|
+
const config = loadConfig();
|
|
2720
|
+
const logger = createLogger(config.logLevel);
|
|
2721
|
+
if (config.transport === "stdio") {
|
|
2722
|
+
await startStdioTransport(buildStdioServer(config, logger), logger);
|
|
2723
|
+
return;
|
|
2724
|
+
}
|
|
2725
|
+
await startHttpTransport(config, logger);
|
|
2137
2726
|
};
|
|
2138
2727
|
main().catch((error) => {
|
|
2139
2728
|
console.error("Fatal error:", error);
|