@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/README.md +218 -91
- package/dist/index.js +435 -33
- package/dist/index.js.map +1 -1
- package/glama.json +4 -0
- package/package.json +5 -4
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 =
|
|
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
|
|
619
|
-
const
|
|
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
|
|
624
|
-
zendeskApiToken
|
|
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
|
|
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:
|
|
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 (
|
|
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,
|
|
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,
|
|
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.
|
|
2313
|
-
|
|
2314
|
-
|
|
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);
|