@fruggr/zendesk-mcp-server 1.8.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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { createHash, randomBytes } from "node:crypto";
2
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
3
3
  import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { createServer } from "node:http";
5
5
  import { homedir, release } from "node:os";
@@ -19,6 +19,7 @@ import remarkParse from "remark-parse";
19
19
  import remarkRehype from "remark-rehype";
20
20
  import remarkStringify from "remark-stringify";
21
21
  import { unified } from "unified";
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
  /**
@@ -566,6 +567,7 @@ const Namespace = z.enum([
566
567
  "help_center",
567
568
  "users"
568
569
  ]);
570
+ const Transport = z.enum(["stdio", "http"]);
569
571
  const ConfigSchema = z.object({
570
572
  subdomain: z.string().min(1, "ZENDESK_SUBDOMAIN is required"),
571
573
  oauthClientId: z.string().min(1),
@@ -576,8 +578,25 @@ const ConfigSchema = z.object({
576
578
  readOnly: z.boolean(),
577
579
  namespaces: z.array(Namespace).optional(),
578
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([]),
579
593
  callbackPort: z.number().int().min(1).max(65535).optional()
580
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);
581
600
  const parseCliArgs = (args) => {
582
601
  const result = {};
583
602
  let positionalIndex = 0;
@@ -600,8 +619,24 @@ const parseCliArgs = (args) => {
600
619
  } else if (arg === "--log-level" && next) {
601
620
  result.logLevel = next;
602
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++;
603
638
  } else if (arg === "--callback-port" && next) {
604
- result.callbackPort = Number(next);
639
+ result.callbackPort = parsePort(next, "--callback-port");
605
640
  i++;
606
641
  } else if (!arg.startsWith("-") && positionalIndex === 0) {
607
642
  result.subdomain = arg;
@@ -615,18 +650,31 @@ const loadConfig = (argv = process.argv.slice(2)) => {
615
650
  const subdomain = cli.subdomain ?? process.env["ZENDESK_SUBDOMAIN"] ?? "";
616
651
  const oauthClientId = process.env["ZENDESK_OAUTH_CLIENT_ID"] ?? (subdomain ? `${subdomain}_zendesk` : "");
617
652
  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);
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.");
620
663
  return ConfigSchema.parse({
621
664
  subdomain,
622
665
  oauthClientId,
623
- zendeskEmail: process.env["ZENDESK_EMAIL"],
624
- zendeskApiToken: process.env["ZENDESK_API_TOKEN"],
666
+ zendeskEmail,
667
+ zendeskApiToken,
625
668
  logLevel: cli.logLevel ?? process.env["LOG_LEVEL"] ?? "info",
626
669
  mode,
627
670
  readOnly: cli.readOnly ?? false,
628
671
  namespaces: cli.namespaces,
629
672
  tools: cli.tools,
673
+ transport,
674
+ host,
675
+ port,
676
+ publicUrl,
677
+ corsOrigins,
630
678
  callbackPort
631
679
  });
632
680
  };
@@ -2231,27 +2279,34 @@ const aggregateAnnotations = (tools) => ({
2231
2279
  idempotentHint: tools.every((t) => t.annotations.idempotentHint),
2232
2280
  openWorldHint: true
2233
2281
  });
2234
- const registerProxyTool = (server, toolName, title, tools, handlerMap, readOnlyMode, onUnauthorized) => {
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) => {
2235
2296
  const operationNames = tools.map((t) => t.name);
2236
2297
  const operationList = buildOperationList(tools);
2237
2298
  const annotations = aggregateAnnotations(tools);
2238
2299
  const prefix = readOnlyMode ? "[RO] " : "";
2300
+ const dispatch = buildProxyDispatch(tools, onUnauthorized);
2239
2301
  server.registerTool(toolName, {
2240
2302
  title,
2241
2303
  description: `${prefix}${title}. Specify the operation and its parameters.\n\nAvailable operations:\n${operationList}`,
2242
- inputSchema: z.object({
2304
+ inputSchema: {
2243
2305
  operation: z.string().describe(`One of: ${operationNames.join(", ")}`),
2244
2306
  params: z.record(z.string(), z.unknown()).default({}).describe("Operation parameters")
2245
- }),
2307
+ },
2246
2308
  annotations
2247
- }, async ({ operation, params }) => {
2248
- const def = handlerMap.get(operation);
2249
- if (!def) return { content: [{
2250
- type: "text",
2251
- text: `Unknown operation "${operation}". Available: ${operationNames.join(", ")}`
2252
- }] };
2253
- return runHandler(def, def.inputSchema.parse(params), onUnauthorized);
2254
- });
2309
+ }, async (args) => dispatch(args));
2255
2310
  };
2256
2311
  const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized) => {
2257
2312
  const pkg = readPackageInfo();
@@ -2268,14 +2323,12 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
2268
2323
  namespaces: config.namespaces,
2269
2324
  tools: config.tools
2270
2325
  });
2271
- const handlerMap = /* @__PURE__ */ new Map();
2272
- for (const tool of filteredTools) handlerMap.set(tool.name, tool);
2273
2326
  switch (config.mode) {
2274
2327
  case "all":
2275
2328
  for (const tool of filteredTools) server.registerTool(tool.name, {
2276
2329
  title: tool.title,
2277
2330
  description: tool.description,
2278
- inputSchema: tool.inputSchema,
2331
+ inputSchema: tool.inputSchema.shape,
2279
2332
  annotations: tool.annotations
2280
2333
  }, async (params) => runHandler(tool, params, onUnauthorized));
2281
2334
  break;
@@ -2283,12 +2336,12 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
2283
2336
  const grouped = groupByNamespace(filteredTools);
2284
2337
  for (const [namespace, tools] of grouped) {
2285
2338
  const label = NAMESPACE_LABELS[namespace];
2286
- if (label) registerProxyTool(server, label.toolName, label.title, tools, handlerMap, config.readOnly, onUnauthorized);
2339
+ if (label) registerProxyTool(server, label.toolName, label.title, tools, config.readOnly, onUnauthorized);
2287
2340
  }
2288
2341
  break;
2289
2342
  }
2290
2343
  case "single":
2291
- registerProxyTool(server, "zendesk", "Zendesk", filteredTools, handlerMap, config.readOnly, onUnauthorized);
2344
+ registerProxyTool(server, "zendesk", "Zendesk", filteredTools, config.readOnly, onUnauthorized);
2292
2345
  break;
2293
2346
  }
2294
2347
  logger.info("tools_registered", {
@@ -2298,6 +2351,350 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
2298
2351
  return server;
2299
2352
  };
2300
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
2301
2698
  //#region src/transports/stdio.ts
2302
2699
  const startStdioTransport = async (server, logger = silentLogger) => {
2303
2700
  const transport = new StdioServerTransport();
@@ -2306,21 +2703,26 @@ const startStdioTransport = async (server, logger = silentLogger) => {
2306
2703
  };
2307
2704
  //#endregion
2308
2705
  //#region src/index.ts
2706
+ const buildStdioServer = (config, logger) => {
2707
+ if (config.zendeskEmail && config.zendeskApiToken) {
2708
+ const staticToken = buildBasicAuthHeader(config.zendeskEmail, config.zendeskApiToken);
2709
+ return createMcpServer(config, () => staticToken, logger);
2710
+ }
2711
+ const tokenStore = createTokenStore({
2712
+ subdomain: config.subdomain,
2713
+ oauthClientId: config.oauthClientId,
2714
+ callbackPort: config.callbackPort
2715
+ }, logger);
2716
+ return createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
2717
+ };
2309
2718
  const main = async () => {
2310
2719
  const config = loadConfig();
2311
2720
  const logger = createLogger(config.logLevel);
2312
- if (config.zendeskEmail && config.zendeskApiToken) {
2313
- const staticToken = buildBasicAuthHeader(config.zendeskEmail, config.zendeskApiToken);
2314
- const getToken = () => staticToken;
2315
- await startStdioTransport(createMcpServer(config, getToken, logger), logger);
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);
2721
+ if (config.transport === "stdio") {
2722
+ await startStdioTransport(buildStdioServer(config, logger), logger);
2723
+ return;
2323
2724
  }
2725
+ await startHttpTransport(config, logger);
2324
2726
  };
2325
2727
  main().catch((error) => {
2326
2728
  console.error("Fatal error:", error);