@fruggr/zendesk-mcp-server 2.24.0 → 3.0.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 +10 -7
- package/dist/http-BXXDmcaZ.js +1383 -0
- package/dist/index.js +82 -406
- package/package.json +26 -6
package/dist/index.js
CHANGED
|
@@ -22,7 +22,6 @@ import remarkRehype from "remark-rehype";
|
|
|
22
22
|
import remarkStringify from "remark-stringify";
|
|
23
23
|
import { unified } from "unified";
|
|
24
24
|
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
25
|
-
import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
|
|
26
25
|
//#region src/utils/logger.ts
|
|
27
26
|
const SEVERITY = {
|
|
28
27
|
debug: 0,
|
|
@@ -119,53 +118,16 @@ const createLogger = (level) => {
|
|
|
119
118
|
};
|
|
120
119
|
//#endregion
|
|
121
120
|
//#region src/utils/env.ts
|
|
122
|
-
const LEGACY_NAMES = /* @__PURE__ */ new Map([
|
|
123
|
-
["OAUTH_TOKEN_FILE", "ZENDESK_TOKEN_FILE"],
|
|
124
|
-
["OAUTH_CALLBACK_PORT", "ZENDESK_OAUTH_CALLBACK_PORT"],
|
|
125
|
-
["LISTEN_HOST", "HOST"],
|
|
126
|
-
["RESPONSE_CHARACTER_LIMIT", "ZENDESK_CHARACTER_LIMIT"],
|
|
127
|
-
["RESPONSE_MAX_BYTES", "ZENDESK_MAX_RESPONSE_BYTES"],
|
|
128
|
-
["ATTACHMENT_MAX_BYTES", "ZENDESK_MAX_ATTACHMENT_BYTES"],
|
|
129
|
-
["EMBEDDED_IMAGES_MAX", "ZENDESK_MAX_EMBEDDED_IMAGES"],
|
|
130
|
-
["COMMENT_MAX_PAGES", "ZENDESK_MAX_COMMENT_PAGES"],
|
|
131
|
-
["TICKET_FIELD_SCAN_MAX_PAGES", "ZENDESK_TICKET_FIELD_SCAN_MAX_PAGES"],
|
|
132
|
-
["ARTICLE_RESOURCES_SCAN_MAX_PAGES", "ZENDESK_ARTICLE_RESOURCES_SCAN_MAX_PAGES"],
|
|
133
|
-
["REORDER_CONFIRM_THRESHOLD", "ZENDESK_REORDER_CONFIRM_THRESHOLD"]
|
|
134
|
-
]);
|
|
135
|
-
const warned = /* @__PURE__ */ new Set();
|
|
136
|
-
const warnDeprecated = (legacy, current) => {
|
|
137
|
-
if (warned.has(legacy)) return;
|
|
138
|
-
warned.add(legacy);
|
|
139
|
-
try {
|
|
140
|
-
console.error(`[zendesk-mcp] [warn] deprecated_env_var name=${legacy} replacement=${current} removal=3.0.0`);
|
|
141
|
-
} catch {}
|
|
142
|
-
};
|
|
143
121
|
/**
|
|
144
|
-
* Reads `name
|
|
145
|
-
*
|
|
146
|
-
*
|
|
122
|
+
* Reads `name` from the environment. `name` in the result is the variable the
|
|
123
|
+
* value came from, so an error about it names what the operator actually set.
|
|
124
|
+
* Every variable goes through here, so a future rename has a single place to
|
|
125
|
+
* map the old name (AGENTS.md, "Environment variables").
|
|
147
126
|
*/
|
|
148
|
-
const readEnv = (name) => {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
name,
|
|
153
|
-
value
|
|
154
|
-
};
|
|
155
|
-
const legacyValue = process.env[legacy];
|
|
156
|
-
if (legacyValue === void 0) return {
|
|
157
|
-
name,
|
|
158
|
-
value
|
|
159
|
-
};
|
|
160
|
-
warnDeprecated(legacy, name);
|
|
161
|
-
return value === void 0 ? {
|
|
162
|
-
name: legacy,
|
|
163
|
-
value: legacyValue
|
|
164
|
-
} : {
|
|
165
|
-
name,
|
|
166
|
-
value
|
|
167
|
-
};
|
|
168
|
-
};
|
|
127
|
+
const readEnv = (name) => ({
|
|
128
|
+
name,
|
|
129
|
+
value: process.env[name]
|
|
130
|
+
});
|
|
169
131
|
//#endregion
|
|
170
132
|
//#region src/constants.ts
|
|
171
133
|
const positiveIntEnv = (name, fallback) => {
|
|
@@ -193,6 +155,15 @@ const getOAuthUrls = (subdomain) => ({
|
|
|
193
155
|
tokenUrl: `https://${subdomain}.zendesk.com/oauth/tokens`
|
|
194
156
|
});
|
|
195
157
|
//#endregion
|
|
158
|
+
//#region src/utils/html.ts
|
|
159
|
+
/**
|
|
160
|
+
* Escape a string for safe interpolation into HTML text/attribute context.
|
|
161
|
+
* Both the stdio callback page and the HTTP consent page echo
|
|
162
|
+
* attacker-controllable values (an OAuth `error_description`, a client name
|
|
163
|
+
* from a fetched metadata document); without escaping these are an XSS sink.
|
|
164
|
+
*/
|
|
165
|
+
const escapeHtml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
166
|
+
//#endregion
|
|
196
167
|
//#region src/auth/oauth-scopes.ts
|
|
197
168
|
/**
|
|
198
169
|
* The OAuth scope the server asks Zendesk for, and the one predicate that
|
|
@@ -260,13 +231,6 @@ const callbackPortInUseError = (port, cause) => Object.assign(/* @__PURE__ */ ne
|
|
|
260
231
|
code: "EADDRINUSE",
|
|
261
232
|
cause
|
|
262
233
|
});
|
|
263
|
-
/**
|
|
264
|
-
* Escape a string for safe interpolation into HTML text/attribute context.
|
|
265
|
-
* The local callback server echoes attacker-controllable values (the OAuth
|
|
266
|
-
* `error_description` query param, token-exchange error bodies) back into the
|
|
267
|
-
* browser response; without escaping these are a reflected-XSS sink.
|
|
268
|
-
*/
|
|
269
|
-
const escapeHtml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
270
234
|
const errorPage = (title, detail) => `<html><body><h1>${escapeHtml(title)}</h1>${detail === void 0 ? "" : `<p>${escapeHtml(detail)}</p>`}</body></html>`;
|
|
271
235
|
const SUCCESS_PAGE = "<html><body><h1>Authentication successful!</h1><p>You can close this tab and return to your AI assistant.</p><p>This tab will auto-close in <span id=\"t\">10</span>s.</p><script>let n=10;const el=document.getElementById(\"t\");const i=setInterval(()=>{n--;el.textContent=n;if(n<=0){clearInterval(i);window.close();}},1000);<\/script></body></html>";
|
|
272
236
|
const generateCodeVerifier = () => randomBytes(32).toString("base64url");
|
|
@@ -871,7 +835,22 @@ const ConfigSchema = z.object({
|
|
|
871
835
|
* because they send no Origin header.
|
|
872
836
|
*/
|
|
873
837
|
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([]),
|
|
874
|
-
callbackPort: z.number().int().min(1).max(65535).optional()
|
|
838
|
+
callbackPort: z.number().int().min(1).max(65535).optional(),
|
|
839
|
+
/**
|
|
840
|
+
* HTTP only: the authorization server's master secret (`OAUTH_MASTER_SECRET`),
|
|
841
|
+
* or a file holding it. Both optional: when absent, one is generated and
|
|
842
|
+
* persisted in the config dir. Belongs to this server, not to Zendesk, hence
|
|
843
|
+
* no `ZENDESK_` prefix. Validated (length, base64) where it is used, so a
|
|
844
|
+
* value never reaches a schema error message.
|
|
845
|
+
*/
|
|
846
|
+
oauthMasterSecret: z.string().optional(),
|
|
847
|
+
oauthMasterSecretFile: z.string().min(1).optional(),
|
|
848
|
+
/** HTTP only: the grant store URI; defaults to a file in the config dir. */
|
|
849
|
+
oauthStore: z.string().url().optional(),
|
|
850
|
+
/** HTTP only: CIMD client ids added to the consent-skip allowlist. */
|
|
851
|
+
oauthTrustedClients: z.array(z.string().url()).default([]),
|
|
852
|
+
/** HTTP only: whether the built-in allowlist (claude.ai, ChatGPT) applies. */
|
|
853
|
+
defaultTrustedClients: z.boolean().default(true)
|
|
875
854
|
});
|
|
876
855
|
const DIGITS_ONLY = /^\d+$/;
|
|
877
856
|
const parsePort = (raw, label) => {
|
|
@@ -909,6 +888,13 @@ const CLI_OPTIONS = {
|
|
|
909
888
|
multiple: true
|
|
910
889
|
},
|
|
911
890
|
"callback-port": { type: "string" },
|
|
891
|
+
"oauth-master-secret-file": { type: "string" },
|
|
892
|
+
"oauth-store": { type: "string" },
|
|
893
|
+
"oauth-trusted-client": {
|
|
894
|
+
type: "string",
|
|
895
|
+
multiple: true
|
|
896
|
+
},
|
|
897
|
+
"no-default-trusted-clients": { type: "boolean" },
|
|
912
898
|
"read-only": { type: "boolean" },
|
|
913
899
|
"no-topology": { type: "boolean" },
|
|
914
900
|
"no-promoted-articles": { type: "boolean" },
|
|
@@ -925,14 +911,18 @@ const FIELD_BY_FLAG = /* @__PURE__ */ new Map([
|
|
|
925
911
|
["transport", "transport"],
|
|
926
912
|
["host", "host"],
|
|
927
913
|
["public-url", "publicUrl"],
|
|
928
|
-
["cors-origin", "corsOrigins"]
|
|
914
|
+
["cors-origin", "corsOrigins"],
|
|
915
|
+
["oauth-master-secret-file", "oauthMasterSecretFile"],
|
|
916
|
+
["oauth-store", "oauthStore"],
|
|
917
|
+
["oauth-trusted-client", "oauthTrustedClients"]
|
|
929
918
|
]);
|
|
930
919
|
const STANDALONE_EFFECTS = /* @__PURE__ */ new Map([
|
|
931
920
|
["read-only", { readOnly: true }],
|
|
932
921
|
["no-topology", { topology: false }],
|
|
933
922
|
["no-promoted-articles", { promotedArticles: false }],
|
|
934
923
|
["dev", { dev: true }],
|
|
935
|
-
["print-tools", { printTools: true }]
|
|
924
|
+
["print-tools", { printTools: true }],
|
|
925
|
+
["no-default-trusted-clients", { defaultTrustedClients: false }]
|
|
936
926
|
]);
|
|
937
927
|
const parseCliArgs = (args) => {
|
|
938
928
|
const { values, positionals } = parseArgs({
|
|
@@ -956,8 +946,9 @@ const parseCliArgs = (args) => {
|
|
|
956
946
|
if (values["callback-port"] !== void 0) result.callbackPort = parsePort(values["callback-port"], "--callback-port");
|
|
957
947
|
return result;
|
|
958
948
|
};
|
|
949
|
+
const splitList = (raw) => (raw ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
959
950
|
const resolveTransportSettings = (cli) => {
|
|
960
|
-
const corsFromEnv = (process.env["CORS_ORIGIN"]
|
|
951
|
+
const corsFromEnv = splitList(process.env["CORS_ORIGIN"]);
|
|
961
952
|
return {
|
|
962
953
|
transport: cli.transport ?? requireNonEmptyEnv("TRANSPORT") ?? "stdio",
|
|
963
954
|
host: cli.host ?? requireNonEmptyEnv("LISTEN_HOST") ?? "0.0.0.0",
|
|
@@ -966,6 +957,13 @@ const resolveTransportSettings = (cli) => {
|
|
|
966
957
|
corsOrigins: [...cli.corsOrigins ?? [], ...corsFromEnv]
|
|
967
958
|
};
|
|
968
959
|
};
|
|
960
|
+
const resolveOAuthServerSettings = (cli) => ({
|
|
961
|
+
oauthMasterSecret: requireNonEmptyEnv("OAUTH_MASTER_SECRET"),
|
|
962
|
+
oauthMasterSecretFile: cli.oauthMasterSecretFile ?? requireNonEmptyEnv("OAUTH_MASTER_SECRET_FILE"),
|
|
963
|
+
oauthStore: cli.oauthStore ?? requireNonEmptyEnv("OAUTH_STORE"),
|
|
964
|
+
oauthTrustedClients: [...cli.oauthTrustedClients ?? [], ...splitList(readEnv("OAUTH_TRUSTED_CLIENTS").value)],
|
|
965
|
+
defaultTrustedClients: cli.defaultTrustedClients
|
|
966
|
+
});
|
|
969
967
|
const loadConfig = (argv = process.argv.slice(2)) => {
|
|
970
968
|
const cli = parseCliArgs(argv);
|
|
971
969
|
const subdomain = cli.subdomain ?? requireNonEmptyEnv("ZENDESK_SUBDOMAIN") ?? "";
|
|
@@ -988,7 +986,8 @@ const loadConfig = (argv = process.argv.slice(2)) => {
|
|
|
988
986
|
dev: cli.dev ?? false,
|
|
989
987
|
printTools: cli.printTools ?? false,
|
|
990
988
|
callbackPort,
|
|
991
|
-
...resolveTransportSettings(cli)
|
|
989
|
+
...resolveTransportSettings(cli),
|
|
990
|
+
...resolveOAuthServerSettings(cli)
|
|
992
991
|
});
|
|
993
992
|
};
|
|
994
993
|
//#endregion
|
|
@@ -5537,358 +5536,34 @@ const renderToolSurface = (config, tools) => {
|
|
|
5537
5536
|
].join("\n");
|
|
5538
5537
|
};
|
|
5539
5538
|
//#endregion
|
|
5540
|
-
//#region src/transports/http.ts
|
|
5541
|
-
const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
|
|
5542
|
-
"0.0.0.0",
|
|
5543
|
-
"::",
|
|
5544
|
-
"*"
|
|
5545
|
-
]);
|
|
5546
|
-
const TRAILING_SLASHES = /\/+$/;
|
|
5547
|
-
const DEFAULT_BROWSER_MCP_CLIENT_ORIGINS = [
|
|
5548
|
-
"https://chatgpt.com",
|
|
5549
|
-
"https://chat.openai.com",
|
|
5550
|
-
"https://claude.ai",
|
|
5551
|
-
"https://gemini.google.com",
|
|
5552
|
-
"https://copilot.microsoft.com",
|
|
5553
|
-
"https://www.perplexity.ai",
|
|
5554
|
-
"https://chat.mistral.ai",
|
|
5555
|
-
"https://grok.com"
|
|
5556
|
-
];
|
|
5557
|
-
const CORS_ALLOWED_METHODS = "GET, POST, DELETE, OPTIONS";
|
|
5558
|
-
const CORS_ALLOWED_HEADERS = "Authorization, Content-Type, Accept, mcp-session-id, mcp-protocol-version, last-event-id";
|
|
5559
|
-
const CORS_EXPOSE_HEADERS = "mcp-session-id";
|
|
5560
|
-
const CORS_MAX_AGE = "600";
|
|
5561
|
-
const LOCALHOST_HOSTNAMES = /* @__PURE__ */ new Set([
|
|
5562
|
-
"localhost",
|
|
5563
|
-
"127.0.0.1",
|
|
5564
|
-
"[::1]"
|
|
5565
|
-
]);
|
|
5566
|
-
const ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
|
|
5539
|
+
//#region src/transports/http-peers.ts
|
|
5567
5540
|
/**
|
|
5568
|
-
*
|
|
5569
|
-
*
|
|
5570
|
-
*
|
|
5571
|
-
*
|
|
5572
|
-
* from one of three sanitization points:
|
|
5573
|
-
*
|
|
5574
|
-
* 1. An entry of the hardcoded `DEFAULT_BROWSER_MCP_CLIENT_ORIGINS` array.
|
|
5575
|
-
* 2. An entry of the operator-configured `extraOrigins` array.
|
|
5576
|
-
* 3. A loopback origin rebuilt from validated URL components after the
|
|
5577
|
-
* hostname has been allowlisted against `LOCALHOST_HOSTNAMES`.
|
|
5578
|
-
*
|
|
5579
|
-
* This shape keeps the dataflow from request header to response header
|
|
5580
|
-
* gated by a constant allowlist, which is the pattern CodeQL's
|
|
5581
|
-
* `js/cors-misconfiguration-for-credentials` rule recognises as safe when
|
|
5582
|
-
* combined with `Access-Control-Allow-Credentials: true`.
|
|
5541
|
+
* Packages only the HTTP transport uses, shipped as optional peer dependencies so
|
|
5542
|
+
* a stdio install (npx, bunx) never downloads them. Inlined from `package.json` at
|
|
5543
|
+
* build time, so a version bump there is the only edit. Rationale:
|
|
5544
|
+
* docs/decisions/oauth-authorization-server.md (Packaging).
|
|
5583
5545
|
*/
|
|
5584
|
-
const
|
|
5585
|
-
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
5546
|
+
const HTTP_PEERS = {
|
|
5547
|
+
"@modelcontextprotocol/node": "^2.0.0",
|
|
5548
|
+
"jose": "^6.2.12",
|
|
5549
|
+
"oidc-provider": "~9.12.2"
|
|
5550
|
+
};
|
|
5551
|
+
const installCommand = () => ["npm install @fruggr/zendesk-mcp-server", ...Object.entries(HTTP_PEERS).map(([name, range]) => `${name}@${range}`)].join(" ");
|
|
5552
|
+
const missingPeers = (err) => {
|
|
5553
|
+
if (err?.code !== "ERR_MODULE_NOT_FOUND") return [];
|
|
5554
|
+
const message = String(err.message);
|
|
5555
|
+
return Object.keys(HTTP_PEERS).filter((name) => message.includes(`'${name}'`));
|
|
5556
|
+
};
|
|
5557
|
+
/** Import the HTTP transport, turning a missing optional peer into an actionable error. */
|
|
5558
|
+
const loadHttpTransport = async (importer = () => import("./http-BXXDmcaZ.js")) => {
|
|
5589
5559
|
try {
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
} catch {
|
|
5596
|
-
return;
|
|
5597
|
-
}
|
|
5598
|
-
};
|
|
5599
|
-
const applyCorsHeaders = (req, res, extraOrigins) => {
|
|
5600
|
-
const requestOrigin = req.headers["origin"];
|
|
5601
|
-
if (typeof requestOrigin !== "string" || requestOrigin.length === 0) return;
|
|
5602
|
-
const allowedOrigin = resolveAllowedOrigin(requestOrigin, extraOrigins);
|
|
5603
|
-
if (!allowedOrigin) return;
|
|
5604
|
-
res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
|
|
5605
|
-
res.setHeader("Vary", "Origin");
|
|
5606
|
-
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
5607
|
-
res.setHeader("Access-Control-Expose-Headers", CORS_EXPOSE_HEADERS);
|
|
5608
|
-
};
|
|
5609
|
-
const handleCorsPreflight = (req, res, extraOrigins) => {
|
|
5610
|
-
if (req.method !== "OPTIONS") return false;
|
|
5611
|
-
applyCorsHeaders(req, res, extraOrigins);
|
|
5612
|
-
if (res.getHeader("Access-Control-Allow-Origin")) {
|
|
5613
|
-
res.setHeader("Access-Control-Allow-Methods", CORS_ALLOWED_METHODS);
|
|
5614
|
-
res.setHeader("Access-Control-Allow-Headers", CORS_ALLOWED_HEADERS);
|
|
5615
|
-
res.setHeader("Access-Control-Max-Age", CORS_MAX_AGE);
|
|
5616
|
-
}
|
|
5617
|
-
res.writeHead(204);
|
|
5618
|
-
res.end();
|
|
5619
|
-
return true;
|
|
5620
|
-
};
|
|
5621
|
-
const resolveResourceUrl = (config, logger = silentLogger) => {
|
|
5622
|
-
if (config.publicUrl) return config.publicUrl.replace(TRAILING_SLASHES, "");
|
|
5623
|
-
if (!WILDCARD_HOSTS.has(config.host)) return `http://${config.host}:${config.port}`;
|
|
5624
|
-
logger.warn("public_url_unset", {
|
|
5625
|
-
host: config.host,
|
|
5626
|
-
advertised: `http://${config.host}:${config.port}`,
|
|
5627
|
-
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)."
|
|
5628
|
-
});
|
|
5629
|
-
return `http://${config.host}:${config.port}`;
|
|
5630
|
-
};
|
|
5631
|
-
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.";
|
|
5632
|
-
const extractBearer = (request) => {
|
|
5633
|
-
const header = request.headers["authorization"];
|
|
5634
|
-
if (typeof header !== "string") return void 0;
|
|
5635
|
-
if (!header.toLowerCase().startsWith("bearer ")) return void 0;
|
|
5636
|
-
return header.slice(7).trim();
|
|
5637
|
-
};
|
|
5638
|
-
const buildOAuthMetadata = (config, logger = silentLogger) => {
|
|
5639
|
-
const { authorizeUrl, tokenUrl } = getOAuthUrls(config.subdomain);
|
|
5640
|
-
const issuer = `https://${config.subdomain}.zendesk.com`;
|
|
5641
|
-
const resource = resolveResourceUrl(config, logger);
|
|
5642
|
-
const scopes = () => supportedScopes(config.readOnly);
|
|
5643
|
-
return {
|
|
5644
|
-
protectedResource: {
|
|
5645
|
-
authorization_servers: [issuer],
|
|
5646
|
-
resource,
|
|
5647
|
-
bearer_methods_supported: ["header"],
|
|
5648
|
-
scopes_supported: scopes()
|
|
5649
|
-
},
|
|
5650
|
-
authorizationServer: {
|
|
5651
|
-
issuer,
|
|
5652
|
-
authorization_endpoint: authorizeUrl,
|
|
5653
|
-
token_endpoint: tokenUrl,
|
|
5654
|
-
response_types_supported: ["code"],
|
|
5655
|
-
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
5656
|
-
code_challenge_methods_supported: ["S256"],
|
|
5657
|
-
token_endpoint_auth_methods_supported: ["none"],
|
|
5658
|
-
scopes_supported: scopes()
|
|
5659
|
-
}
|
|
5660
|
-
};
|
|
5661
|
-
};
|
|
5662
|
-
const sendJson = (res, status, body) => {
|
|
5663
|
-
res.writeHead(status, { "Content-Type": "application/json" });
|
|
5664
|
-
res.end(JSON.stringify(body));
|
|
5665
|
-
};
|
|
5666
|
-
const sendJsonRpcError = (res, status, code, message, headers = {}) => {
|
|
5667
|
-
res.writeHead(status, {
|
|
5668
|
-
"Content-Type": "application/json",
|
|
5669
|
-
...headers
|
|
5670
|
-
});
|
|
5671
|
-
res.end(JSON.stringify({
|
|
5672
|
-
error: {
|
|
5673
|
-
code,
|
|
5674
|
-
message
|
|
5675
|
-
},
|
|
5676
|
-
id: null,
|
|
5677
|
-
jsonrpc: "2.0"
|
|
5678
|
-
}));
|
|
5679
|
-
};
|
|
5680
|
-
const failRequest = (res, err) => {
|
|
5681
|
-
const message = err instanceof Error ? err.message : "Internal Server Error";
|
|
5682
|
-
if (!res.headersSent) {
|
|
5683
|
-
sendJsonRpcError(res, 500, -32603, message);
|
|
5684
|
-
return;
|
|
5685
|
-
}
|
|
5686
|
-
if (!res.writableEnded) res.end();
|
|
5687
|
-
};
|
|
5688
|
-
const sendUnauthorized = (res, resource) => {
|
|
5689
|
-
const wwwAuthenticate = `Bearer resource_metadata="${resource}/.well-known/oauth-protected-resource", error="invalid_token", error_description="${MISSING_BEARER_MESSAGE}"`;
|
|
5690
|
-
sendJsonRpcError(res, 401, -32e3, MISSING_BEARER_MESSAGE, { "WWW-Authenticate": wwwAuthenticate });
|
|
5691
|
-
};
|
|
5692
|
-
const readJsonBody = (req, maxBodyBytes) => new Promise((resolve) => {
|
|
5693
|
-
const chunks = [];
|
|
5694
|
-
let total = 0;
|
|
5695
|
-
let settled = false;
|
|
5696
|
-
const settle = (result) => {
|
|
5697
|
-
if (settled) return;
|
|
5698
|
-
settled = true;
|
|
5699
|
-
resolve(result);
|
|
5700
|
-
};
|
|
5701
|
-
req.on("data", (chunk) => {
|
|
5702
|
-
total += chunk.length;
|
|
5703
|
-
if (total > maxBodyBytes) {
|
|
5704
|
-
req.removeAllListeners("data");
|
|
5705
|
-
settle({
|
|
5706
|
-
ok: false,
|
|
5707
|
-
status: 413,
|
|
5708
|
-
rpcCode: -32600,
|
|
5709
|
-
message: `Request body exceeds ${maxBodyBytes} bytes.`
|
|
5710
|
-
});
|
|
5711
|
-
return;
|
|
5712
|
-
}
|
|
5713
|
-
chunks.push(chunk);
|
|
5714
|
-
});
|
|
5715
|
-
req.on("end", () => {
|
|
5716
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
5717
|
-
if (!raw) {
|
|
5718
|
-
settle({
|
|
5719
|
-
ok: true,
|
|
5720
|
-
value: void 0
|
|
5721
|
-
});
|
|
5722
|
-
return;
|
|
5723
|
-
}
|
|
5724
|
-
try {
|
|
5725
|
-
settle({
|
|
5726
|
-
ok: true,
|
|
5727
|
-
value: JSON.parse(raw)
|
|
5728
|
-
});
|
|
5729
|
-
} catch {
|
|
5730
|
-
settle({
|
|
5731
|
-
ok: false,
|
|
5732
|
-
status: 400,
|
|
5733
|
-
rpcCode: -32700,
|
|
5734
|
-
message: "Parse error: request body is not valid JSON."
|
|
5735
|
-
});
|
|
5736
|
-
}
|
|
5737
|
-
});
|
|
5738
|
-
req.on("error", () => settle({
|
|
5739
|
-
ok: false,
|
|
5740
|
-
status: 400,
|
|
5741
|
-
rpcCode: -32600,
|
|
5742
|
-
message: "Request body could not be read."
|
|
5743
|
-
}));
|
|
5744
|
-
});
|
|
5745
|
-
const respondBodyError = (req, res, failure) => {
|
|
5746
|
-
const headers = failure.status === 413 ? { Connection: "close" } : {};
|
|
5747
|
-
sendJsonRpcError(res, failure.status, failure.rpcCode, failure.message, headers);
|
|
5748
|
-
if (failure.status === 413) {
|
|
5749
|
-
if (res.writableFinished) req.destroy();
|
|
5750
|
-
else res.once("finish", () => req.destroy());
|
|
5560
|
+
return await importer();
|
|
5561
|
+
} catch (err) {
|
|
5562
|
+
const missing = missingPeers(err);
|
|
5563
|
+
if (missing.length === 0) throw err;
|
|
5564
|
+
throw new Error(`The HTTP transport needs optional packages that are not installed (missing: ${missing.join(", ")}). Install them next to the server:\n ${installCommand()}\nstdio does not need them. See docs/http-deployment.md.`, { cause: err });
|
|
5751
5565
|
}
|
|
5752
5566
|
};
|
|
5753
|
-
const SESSION_IDLE_TIMEOUT_MS = 18e5;
|
|
5754
|
-
const SESSION_SWEEP_INTERVAL_MS = 6e4;
|
|
5755
|
-
const startHttpTransport = async (config, logger = silentLogger, options = {}) => {
|
|
5756
|
-
const metadata = buildOAuthMetadata(config, logger);
|
|
5757
|
-
const sessions = /* @__PURE__ */ new Map();
|
|
5758
|
-
const idleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS;
|
|
5759
|
-
const maxBodyBytes = options.maxBodyBytes ?? 4194304;
|
|
5760
|
-
const dispatchToSession = async (req, res, sessionId, bearer) => {
|
|
5761
|
-
const session = sessions.get(sessionId);
|
|
5762
|
-
if (!session) return false;
|
|
5763
|
-
session.auth.bearer = bearer;
|
|
5764
|
-
session.lastActivityAt = Date.now();
|
|
5765
|
-
const body = req.method === "POST" ? await readJsonBody(req, maxBodyBytes) : {
|
|
5766
|
-
ok: true,
|
|
5767
|
-
value: void 0
|
|
5768
|
-
};
|
|
5769
|
-
if (!body.ok) {
|
|
5770
|
-
respondBodyError(req, res, body);
|
|
5771
|
-
return true;
|
|
5772
|
-
}
|
|
5773
|
-
await session.transport.handleRequest(req, res, body.value);
|
|
5774
|
-
return true;
|
|
5775
|
-
};
|
|
5776
|
-
const handleMcpRequest = async (req, res) => {
|
|
5777
|
-
const bearer = extractBearer(req);
|
|
5778
|
-
if (!bearer) {
|
|
5779
|
-
sendUnauthorized(res, metadata.protectedResource.resource);
|
|
5780
|
-
return;
|
|
5781
|
-
}
|
|
5782
|
-
const sessionId = typeof req.headers["mcp-session-id"] === "string" ? req.headers["mcp-session-id"] : void 0;
|
|
5783
|
-
if (sessionId && await dispatchToSession(req, res, sessionId, bearer)) return;
|
|
5784
|
-
if (req.method !== "POST") {
|
|
5785
|
-
sendJsonRpcError(res, 400, -32e3, "No active session; initialize via POST first.");
|
|
5786
|
-
return;
|
|
5787
|
-
}
|
|
5788
|
-
const body = await readJsonBody(req, maxBodyBytes);
|
|
5789
|
-
if (!body.ok) {
|
|
5790
|
-
respondBodyError(req, res, body);
|
|
5791
|
-
return;
|
|
5792
|
-
}
|
|
5793
|
-
const auth = { bearer };
|
|
5794
|
-
const server = createMcpServer(config, () => auth.bearer, logger);
|
|
5795
|
-
const transport = new NodeStreamableHTTPServerTransport({
|
|
5796
|
-
sessionIdGenerator: () => randomUUID(),
|
|
5797
|
-
onsessioninitialized: (newId) => {
|
|
5798
|
-
sessions.set(newId, {
|
|
5799
|
-
transport,
|
|
5800
|
-
auth,
|
|
5801
|
-
lastActivityAt: Date.now(),
|
|
5802
|
-
close: async () => {
|
|
5803
|
-
await transport.close();
|
|
5804
|
-
await server.close();
|
|
5805
|
-
}
|
|
5806
|
-
});
|
|
5807
|
-
}
|
|
5808
|
-
});
|
|
5809
|
-
transport.onclose = () => {
|
|
5810
|
-
if (transport.sessionId) sessions.delete(transport.sessionId);
|
|
5811
|
-
};
|
|
5812
|
-
await server.connect(transport);
|
|
5813
|
-
await transport.handleRequest(req, res, body.value);
|
|
5814
|
-
};
|
|
5815
|
-
const staticGetRoutes = {
|
|
5816
|
-
"/.well-known/oauth-protected-resource": metadata.protectedResource,
|
|
5817
|
-
"/.well-known/oauth-authorization-server": metadata.authorizationServer,
|
|
5818
|
-
"/healthz": {
|
|
5819
|
-
status: "ok",
|
|
5820
|
-
subdomain: config.subdomain
|
|
5821
|
-
}
|
|
5822
|
-
};
|
|
5823
|
-
const requestListener = async (req, res) => {
|
|
5824
|
-
try {
|
|
5825
|
-
if (handleCorsPreflight(req, res, config.corsOrigins)) return;
|
|
5826
|
-
applyCorsHeaders(req, res, config.corsOrigins);
|
|
5827
|
-
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
5828
|
-
const staticRoute = req.method === "GET" ? staticGetRoutes[url.pathname] : void 0;
|
|
5829
|
-
if (staticRoute) {
|
|
5830
|
-
sendJson(res, 200, staticRoute);
|
|
5831
|
-
return;
|
|
5832
|
-
}
|
|
5833
|
-
if (url.pathname === "/mcp") {
|
|
5834
|
-
await handleMcpRequest(req, res);
|
|
5835
|
-
return;
|
|
5836
|
-
}
|
|
5837
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
5838
|
-
res.end(JSON.stringify({
|
|
5839
|
-
error: "Not found",
|
|
5840
|
-
path: url.pathname
|
|
5841
|
-
}));
|
|
5842
|
-
} catch (err) {
|
|
5843
|
-
failRequest(res, err);
|
|
5844
|
-
}
|
|
5845
|
-
};
|
|
5846
|
-
const httpServer = createServer((req, res) => {
|
|
5847
|
-
requestListener(req, res);
|
|
5848
|
-
});
|
|
5849
|
-
await new Promise((resolve, reject) => {
|
|
5850
|
-
httpServer.once("error", reject);
|
|
5851
|
-
httpServer.listen(config.port, config.host, () => {
|
|
5852
|
-
httpServer.off("error", reject);
|
|
5853
|
-
resolve();
|
|
5854
|
-
});
|
|
5855
|
-
});
|
|
5856
|
-
const addr = httpServer.address();
|
|
5857
|
-
const boundPort = typeof addr === "object" && addr !== null ? addr.port : config.port;
|
|
5858
|
-
logger.info("http_transport_ready", {
|
|
5859
|
-
host: config.host,
|
|
5860
|
-
port: boundPort
|
|
5861
|
-
});
|
|
5862
|
-
const sweepIdleSessions = async () => {
|
|
5863
|
-
const cutoff = Date.now() - idleTimeoutMs;
|
|
5864
|
-
for (const [id, session] of sessions) {
|
|
5865
|
-
if (session.lastActivityAt > cutoff) continue;
|
|
5866
|
-
sessions.delete(id);
|
|
5867
|
-
try {
|
|
5868
|
-
await session.close();
|
|
5869
|
-
} catch (err) {
|
|
5870
|
-
logger.warn("session_close_failed", {
|
|
5871
|
-
sessionId: id,
|
|
5872
|
-
error: err instanceof Error ? err.message : String(err)
|
|
5873
|
-
});
|
|
5874
|
-
}
|
|
5875
|
-
}
|
|
5876
|
-
};
|
|
5877
|
-
const sweeper = setInterval(() => void sweepIdleSessions(), options.sweepIntervalMs ?? SESSION_SWEEP_INTERVAL_MS);
|
|
5878
|
-
sweeper.unref();
|
|
5879
|
-
return {
|
|
5880
|
-
port: boundPort,
|
|
5881
|
-
close: async () => {
|
|
5882
|
-
clearInterval(sweeper);
|
|
5883
|
-
await Promise.all([...sessions.values()].map((session) => session.close()));
|
|
5884
|
-
sessions.clear();
|
|
5885
|
-
httpServer.closeAllConnections();
|
|
5886
|
-
await new Promise((resolve, reject) => {
|
|
5887
|
-
httpServer.close((err) => err ? reject(err) : resolve());
|
|
5888
|
-
});
|
|
5889
|
-
}
|
|
5890
|
-
};
|
|
5891
|
-
};
|
|
5892
5567
|
//#endregion
|
|
5893
5568
|
//#region src/utils/shutdown.ts
|
|
5894
5569
|
/**
|
|
@@ -6011,6 +5686,7 @@ const main = async () => {
|
|
|
6011
5686
|
return;
|
|
6012
5687
|
}
|
|
6013
5688
|
if (config.dev) logger.warn("dev_mode_ignored_http");
|
|
5689
|
+
const { startHttpTransport } = await loadHttpTransport();
|
|
6014
5690
|
const http = await startHttpTransport(config, logger);
|
|
6015
5691
|
installShutdown({
|
|
6016
5692
|
watchStdin: false,
|
|
@@ -6023,4 +5699,4 @@ main().catch((error) => {
|
|
|
6023
5699
|
process.exit(1);
|
|
6024
5700
|
});
|
|
6025
5701
|
//#endregion
|
|
6026
|
-
export {};
|
|
5702
|
+
export { configDir as a, requestedScope as c, getOAuthUrls as d, silentLogger as f, deadlineSignal as i, supportedScopes as l, zendeskGet as n, generateCodeChallenge as o, REQUEST_TIMEOUT_MS as r, generateCodeVerifier as s, createMcpServer as t, escapeHtml as u };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fruggr/zendesk-mcp-server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"mcpName": "io.github.fruggr/zendesk-mcp-server",
|
|
5
5
|
"description": "Deep Zendesk MCP server for your AI assistant: search, draft, update and translate Help Center articles and manage Support tickets end to end — comments, triage and image attachments.",
|
|
6
6
|
"type": "module",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"typecheck": "node scripts/typecheck.mjs",
|
|
19
19
|
"start": "node dist/index.js",
|
|
20
20
|
"dev": "tsx watch src/index.ts",
|
|
21
|
+
"dev:http": "tsx src/index.ts --transport http --host 127.0.0.1 --port 3000 --public-url http://localhost:3000 --mode all --namespace tickets --namespace help_center --namespace users --namespace requests",
|
|
21
22
|
"prepare": "npm run build && (lefthook install || true)",
|
|
22
23
|
"prepublishOnly": "npm run build",
|
|
23
24
|
"check": "node scripts/biome.mjs check --error-on-warnings .",
|
|
@@ -68,11 +69,10 @@
|
|
|
68
69
|
"access": "public"
|
|
69
70
|
},
|
|
70
71
|
"engines": {
|
|
71
|
-
"node": ">=
|
|
72
|
+
"node": ">=22"
|
|
72
73
|
},
|
|
73
|
-
"packageManager": "pnpm@12.
|
|
74
|
+
"packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec",
|
|
74
75
|
"dependencies": {
|
|
75
|
-
"@modelcontextprotocol/node": "2.0.0",
|
|
76
76
|
"@modelcontextprotocol/server": "2.0.0",
|
|
77
77
|
"cheerio": "1.2.0",
|
|
78
78
|
"hast-util-to-html": "9.0.5",
|
|
@@ -89,9 +89,26 @@
|
|
|
89
89
|
"unified": "11.0.5",
|
|
90
90
|
"zod": "4.6.5"
|
|
91
91
|
},
|
|
92
|
+
"peerDependencies": {
|
|
93
|
+
"@modelcontextprotocol/node": "^2.0.0",
|
|
94
|
+
"jose": "^6.2.12",
|
|
95
|
+
"oidc-provider": "~9.12.2"
|
|
96
|
+
},
|
|
97
|
+
"peerDependenciesMeta": {
|
|
98
|
+
"@modelcontextprotocol/node": {
|
|
99
|
+
"optional": true
|
|
100
|
+
},
|
|
101
|
+
"jose": {
|
|
102
|
+
"optional": true
|
|
103
|
+
},
|
|
104
|
+
"oidc-provider": {
|
|
105
|
+
"optional": true
|
|
106
|
+
}
|
|
107
|
+
},
|
|
92
108
|
"devDependencies": {
|
|
93
|
-
"@biomejs/biome": "2.5.
|
|
109
|
+
"@biomejs/biome": "2.5.14",
|
|
94
110
|
"@modelcontextprotocol/client": "2.0.0",
|
|
111
|
+
"@modelcontextprotocol/node": "2.0.0",
|
|
95
112
|
"@semantic-release/changelog": "^7.0.0",
|
|
96
113
|
"@semantic-release/exec": "^7.1.0",
|
|
97
114
|
"@semantic-release/git": "^11.0.0",
|
|
@@ -100,14 +117,17 @@
|
|
|
100
117
|
"@semantic-release/release-notes-generator": "^14.1.1",
|
|
101
118
|
"@stryker-mutator/core": "^10.0.0",
|
|
102
119
|
"@stryker-mutator/vitest-runner": "^10.0.0",
|
|
103
|
-
"@tsconfig/
|
|
120
|
+
"@tsconfig/node22": "^22.0.6",
|
|
104
121
|
"@tsconfig/strictest": "^2.0.8",
|
|
105
122
|
"@types/hast": "^3.0.4",
|
|
106
123
|
"@types/node": "^22.15.0",
|
|
124
|
+
"@types/oidc-provider": "9.12.1",
|
|
107
125
|
"@vitest/coverage-v8": "^5.0.0",
|
|
108
126
|
"conventional-changelog-conventionalcommits": "^10.2.1",
|
|
127
|
+
"jose": "6.2.12",
|
|
109
128
|
"lefthook": "^2.1.10",
|
|
110
129
|
"msw": "^2.12.13",
|
|
130
|
+
"oidc-provider": "~9.12.2",
|
|
111
131
|
"semantic-release": "^25.0.3",
|
|
112
132
|
"shx": "^0.4.0",
|
|
113
133
|
"tsdown": "^0.23.0",
|