@fruggr/zendesk-mcp-server 1.3.0 → 1.4.1

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 CHANGED
@@ -1,6 +1,11 @@
1
1
  # Zendesk MCP Server
2
2
 
3
- [![npm](https://img.shields.io/npm/v/@fruggr/zendesk-mcp-server)](https://www.npmjs.com/package/@fruggr/zendesk-mcp-server)
3
+ [![Glama score](https://glama.ai/mcp/servers/fruggr/zendesk-mcp-server/badges/score.svg)](https://glama.ai/mcp/servers/fruggr/zendesk-mcp-server)
4
+ [![npm version](https://img.shields.io/npm/v/@fruggr/zendesk-mcp-server?logo=npm&color=cb3837)](https://www.npmjs.com/package/@fruggr/zendesk-mcp-server)
5
+ [![License: MIT](https://img.shields.io/npm/l/@fruggr/zendesk-mcp-server?color=blue)](LICENSE)
6
+ [![Node.js](https://img.shields.io/node/v/@fruggr/zendesk-mcp-server?logo=nodedotjs&logoColor=white&color=339933)](https://nodejs.org)
7
+ [![Renovate enabled](https://img.shields.io/badge/renovate-enabled-brightgreen?logo=renovatebot&logoColor=white)](https://renovatebot.com)
8
+ [![semantic-release](https://img.shields.io/badge/semantic--release-e10079?logo=semantic-release&logoColor=white)](https://github.com/semantic-release/semantic-release)
4
9
 
5
10
  A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that connects LLMs to the **Zendesk Support & Help Center APIs** — with per-user OAuth 2.1 PKCE authentication and fine-grained tool visibility controls.
6
11
 
@@ -16,6 +21,21 @@ Most Zendesk integrations use a shared admin API key, giving every user full acc
16
21
 
17
22
  > Built and maintained by [Digital4better](https://digital4better.com) for the [Fruggr](https://www.fruggr.io) project.
18
23
 
24
+ ## When to use this server
25
+
26
+ **Reach for it when:**
27
+
28
+ - You want an LLM to read or triage **Zendesk tickets** and **Help Center articles** on behalf of a real user, with that user's own permissions — not a shared admin key.
29
+ - You're editing **large Help Center articles** and want section-scoped reads/rewrites instead of round-tripping the full HTML body through the model.
30
+ - You need to **cap the tool surface** — read-only assistants, a single namespace, or one unified tool to fit a tight context budget.
31
+ - You run a **stdio MCP client** (Claude Desktop, Claude Code, Cursor, VS Code, Cline, …) and want a `npx`-installable server with no extra infrastructure.
32
+
33
+ **Look elsewhere when:**
34
+
35
+ - You need Zendesk products outside Support & Guide (e.g. Talk, Explore analytics, Sell) — those endpoints aren't covered.
36
+ - You want a hosted/remote HTTP server: this one speaks stdio and runs next to the client.
37
+ - You need a single shared service account for all users — that's the opposite of this server's per-user OAuth model (use API-token auth if you must, but one identity then applies to everyone).
38
+
19
39
  ## Tool modes
20
40
 
21
41
  The server registers tools in one of three modes, controlled by `--mode`:
@@ -291,10 +311,38 @@ zendesk-mcp-server acme --tool get_ticket --tool search_tickets --tool get_curre
291
311
  | `ZENDESK_OAUTH_CLIENT_ID` | no | `<subdomain>_zendesk` | OAuth client identifier |
292
312
  | `ZENDESK_EMAIL` | for API token auth | — | Agent email for Basic auth |
293
313
  | `ZENDESK_API_TOKEN` | for API token auth | — | Zendesk API token |
294
- | `LOG_LEVEL` | no | `info` | Log verbosity |
314
+ | `LOG_LEVEL` | no | `info` | Log verbosity (`debug` surfaces the full OAuth flow trace) |
295
315
 
296
316
  If both `ZENDESK_EMAIL` and `ZENDESK_API_TOKEN` are set, the server uses API token auth. Otherwise, it uses OAuth 2.1 PKCE.
297
317
 
318
+ ## Troubleshooting
319
+
320
+ ### The browser doesn't open during OAuth login
321
+
322
+ The OAuth flow opens your default browser on the first tool call. If it doesn't
323
+ open (common in sandboxed or remote desktop environments), the authorization URL
324
+ is still printed to the server's stderr — open it manually.
325
+
326
+ To collect diagnostics, restart with `LOG_LEVEL=debug`. The server then emits
327
+ structured logs through **two channels**, so they're reachable on any MCP client:
328
+
329
+ - **stderr** — captured to a log file by every mainstream client.
330
+ - **MCP logging notifications** (`notifications/message`) — surfaced by clients
331
+ that support the `logging` capability.
332
+
333
+ When the browser fails to open, look for the `oauth_browser_open_failed` event:
334
+ it reports the underlying error, the platform, and which environment markers are
335
+ present (no secrets, tokens, or env values are ever logged).
336
+
337
+ Where each client writes the server's stderr:
338
+
339
+ | Client | Log location |
340
+ |--------|--------------|
341
+ | Claude Desktop (macOS) | `~/Library/Logs/Claude/mcp-server-*.log` |
342
+ | Claude Desktop (Windows) | `%APPDATA%\Claude\logs\mcp-server-*.log` |
343
+ | Claude Code | `claude --debug`, or the session logs |
344
+ | Cursor / VS Code / Cline | the extension's MCP output/log panel |
345
+
298
346
  ## Development
299
347
 
300
348
  ### Toolchain
@@ -348,6 +396,43 @@ Versions follow [SemVer](https://semver.org/) and are calculated **automatically
348
396
  | `feat!:`, `fix!:`, or a `BREAKING CHANGE:` footer | major |
349
397
  | `docs:`, `chore:`, `refactor:`, `test:`, `ci:`, `style:`, `build:` | no release |
350
398
 
399
+ ## FAQ
400
+
401
+ **Do I need a Zendesk admin API key?**
402
+ No. The default OAuth 2.1 PKCE flow means each user authenticates with their own
403
+ credentials and the server acts with exactly their permissions. API-token auth is
404
+ available for headless/CI use (see [Authentication](#authentication)).
405
+
406
+ **Which Zendesk products are supported?**
407
+ Zendesk Support (tickets, users, organizations) and the Help Center / Guide
408
+ (articles, sections, categories, translations, labels, content tags, segments,
409
+ attachments). Talk, Explore, and Sell are out of scope.
410
+
411
+ **How do I keep the model's context small?**
412
+ Use `--mode single` (one `zendesk` tool) or `--mode namespace` (three proxies),
413
+ and `--read-only` to drop write operations. For big articles, the section-based
414
+ tools (`get_article_outline`, `get_article_section`, `update_article_section`)
415
+ let the model touch one section at a time instead of the whole HTML body.
416
+
417
+ **Can I restrict it to read-only?**
418
+ Yes — pass `--read-only` and every write tool is filtered out before the proxies
419
+ are built, in any mode.
420
+
421
+ **Which Node.js version do I need?**
422
+ Node.js >= 20 to run the published package (`engines.node`). The dev toolchain
423
+ uses a newer Node — see [Development](#development).
424
+
425
+ **The OAuth browser window didn't open. What now?**
426
+ The authorization URL is also printed to stderr — open it manually. Restart with
427
+ `LOG_LEVEL=debug` for the full flow trace. See
428
+ [Troubleshooting](#troubleshooting).
429
+
430
+ **Is it safe to run via `npx`?**
431
+ Releases are published from CI via npm Trusted Publishing (OIDC), so each version
432
+ carries a build provenance attestation you can verify on its
433
+ [npm page](https://www.npmjs.com/package/@fruggr/zendesk-mcp-server). No secrets
434
+ are ever logged by the server.
435
+
351
436
  ## Contributing
352
437
 
353
438
  Pull requests are welcome — including AI-assisted ones, as long as the human author has read and validated every line.
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash, randomBytes } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
3
4
  import { createServer } from "node:http";
5
+ import { release } from "node:os";
4
6
  import open from "open";
5
7
  import * as z from "zod/v4";
6
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -15,6 +17,8 @@ import remarkParse from "remark-parse";
15
17
  import remarkRehype from "remark-rehype";
16
18
  import remarkStringify from "remark-stringify";
17
19
  import { unified } from "unified";
20
+ import { dirname, join } from "node:path";
21
+ import { fileURLToPath } from "node:url";
18
22
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
23
  //#region src/auth/api-token.ts
20
24
  /**
@@ -26,6 +30,91 @@ const buildBasicAuthHeader = (email, apiToken) => {
26
30
  return `Basic ${Buffer.from(credentials).toString("base64")}`;
27
31
  };
28
32
  //#endregion
33
+ //#region src/utils/logger.ts
34
+ const SEVERITY = {
35
+ debug: 0,
36
+ info: 1,
37
+ warn: 2,
38
+ error: 3
39
+ };
40
+ const MCP_LEVEL = {
41
+ debug: "debug",
42
+ info: "info",
43
+ warn: "warning",
44
+ error: "error"
45
+ };
46
+ const REDACTED_KEYS = new Set([
47
+ "token",
48
+ "accesstoken",
49
+ "refreshtoken",
50
+ "code",
51
+ "codeverifier",
52
+ "authorization",
53
+ "password",
54
+ "secret",
55
+ "apitoken",
56
+ "bearer"
57
+ ]);
58
+ const isSensitive = (key) => REDACTED_KEYS.has(key.toLowerCase().replace(/[_-]/g, ""));
59
+ const redactValue = (value) => {
60
+ if (Array.isArray(value)) return value.map(redactValue);
61
+ if (value && typeof value === "object") {
62
+ const out = {};
63
+ for (const [key, val] of Object.entries(value)) out[key] = isSensitive(key) ? "[REDACTED]" : redactValue(val);
64
+ return out;
65
+ }
66
+ return value;
67
+ };
68
+ const renderValue = (value) => {
69
+ if (typeof value === "string") return value;
70
+ if (typeof value === "number" || typeof value === "boolean" || value === null) return String(value);
71
+ try {
72
+ return JSON.stringify(value);
73
+ } catch {
74
+ return "[unserializable]";
75
+ }
76
+ };
77
+ const formatLine = (level, event, fields) => {
78
+ const parts = Object.entries(fields).map(([key, value]) => `${key}=${renderValue(value)}`);
79
+ return `[zendesk-mcp] [${level}] ${event}${parts.length ? ` ${parts.join(" ")}` : ""}`;
80
+ };
81
+ const noop = () => {};
82
+ const silentLogger = {
83
+ debug: noop,
84
+ info: noop,
85
+ warn: noop,
86
+ error: noop,
87
+ attachServer: noop
88
+ };
89
+ const createLogger = (level) => {
90
+ const min = SEVERITY[level];
91
+ let server;
92
+ const emit = (lvl, event, fields) => {
93
+ if (SEVERITY[lvl] < min) return;
94
+ const safe = fields ? redactValue(fields) : {};
95
+ console.error(formatLine(lvl, event, safe));
96
+ if (server) try {
97
+ server.sendLoggingMessage({
98
+ level: MCP_LEVEL[lvl],
99
+ logger: "zendesk-mcp-server",
100
+ data: {
101
+ ...safe,
102
+ event
103
+ }
104
+ }).catch(noop);
105
+ } catch {}
106
+ };
107
+ return {
108
+ debug: (event, fields) => emit("debug", event, fields),
109
+ info: (event, fields) => emit("info", event, fields),
110
+ warn: (event, fields) => emit("warn", event, fields),
111
+ error: (event, fields) => emit("error", event, fields),
112
+ attachServer: (s) => {
113
+ server = s;
114
+ }
115
+ };
116
+ };
117
+ //#endregion
29
118
  //#region src/constants.ts
30
119
  const CHARACTER_LIMIT = 25e3;
31
120
  const MAX_COMMENT_PAGES = Number(process.env["ZENDESK_MAX_COMMENT_PAGES"] ?? 10);
@@ -38,6 +127,23 @@ const getOAuthUrls = (subdomain) => ({
38
127
  //#endregion
39
128
  //#region src/auth/browser-oauth.ts
40
129
  const DEFAULT_CALLBACK_PORT = 3e3;
130
+ const AUTH_TIMEOUT_MS = 300 * 1e3;
131
+ /** Best-effort WSL detection: WSL kernels carry "microsoft" in /proc/version. */
132
+ const detectWsl = () => {
133
+ if (process.platform !== "linux") return false;
134
+ try {
135
+ return readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft");
136
+ } catch {
137
+ return false;
138
+ }
139
+ };
140
+ /**
141
+ * Escape a string for safe interpolation into HTML text/attribute context.
142
+ * The local callback server echoes attacker-controllable values (the OAuth
143
+ * `error_description` query param, token-exchange error bodies) back into the
144
+ * browser response; without escaping these are a reflected-XSS sink.
145
+ */
146
+ const escapeHtml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
41
147
  const generateCodeVerifier = () => randomBytes(32).toString("base64url");
42
148
  const generateCodeChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
43
149
  /**
@@ -45,13 +151,14 @@ const generateCodeChallenge = (verifier) => createHash("sha256").update(verifier
45
151
  * Starts a temporary HTTP server to receive the callback.
46
152
  * Returns the access token on success.
47
153
  */
48
- const authenticateViaBrowser = (config) => {
154
+ const authenticateViaBrowser = (config, logger = silentLogger) => {
49
155
  const { subdomain, oauthClientId } = config;
50
156
  const { authorizeUrl, tokenUrl } = getOAuthUrls(subdomain);
51
157
  const codeVerifier = generateCodeVerifier();
52
158
  const codeChallenge = generateCodeChallenge(codeVerifier);
53
159
  return new Promise((resolve, reject) => {
54
160
  let callbackServer;
161
+ let authTimeout;
55
162
  callbackServer = createServer(async (req, res) => {
56
163
  const url = new URL(req.url ?? "/", `http://localhost`);
57
164
  if (url.pathname !== "/callback") {
@@ -61,10 +168,15 @@ const authenticateViaBrowser = (config) => {
61
168
  }
62
169
  const code = url.searchParams.get("code");
63
170
  const error = url.searchParams.get("error");
171
+ logger.debug("oauth_callback_received", {
172
+ hasCode: Boolean(code),
173
+ hasError: Boolean(error)
174
+ });
64
175
  if (error) {
65
176
  const desc = url.searchParams.get("error_description") ?? error;
66
177
  res.writeHead(400, { "Content-Type": "text/html" });
67
- res.end(`<html><body><h1>Authentication failed</h1><p>${desc}</p></body></html>`);
178
+ res.end(`<html><body><h1>Authentication failed</h1><p>${escapeHtml(desc)}</p></body></html>`);
179
+ clearTimeout(authTimeout);
68
180
  callbackServer.close();
69
181
  reject(/* @__PURE__ */ new Error(`OAuth error: ${desc}`));
70
182
  return;
@@ -72,6 +184,7 @@ const authenticateViaBrowser = (config) => {
72
184
  if (!code) {
73
185
  res.writeHead(400, { "Content-Type": "text/html" });
74
186
  res.end("<html><body><h1>Missing authorization code</h1></body></html>");
187
+ clearTimeout(authTimeout);
75
188
  callbackServer.close();
76
189
  reject(/* @__PURE__ */ new Error("Missing authorization code in callback"));
77
190
  return;
@@ -90,24 +203,29 @@ const authenticateViaBrowser = (config) => {
90
203
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
91
204
  body: tokenBody.toString()
92
205
  });
206
+ logger.debug("oauth_token_exchange", { status: tokenResponse.status });
93
207
  if (!tokenResponse.ok) {
94
208
  const errorBody = await tokenResponse.text();
95
209
  throw new Error(`Token exchange failed (${tokenResponse.status}): ${errorBody}`);
96
210
  }
97
211
  const tokenData = await tokenResponse.json();
212
+ logger.info("oauth_authenticated");
98
213
  res.writeHead(200, { "Content-Type": "text/html" });
99
214
  res.end("<html><body><h1>Authentication successful!</h1><p>You can close this tab and return to Claude Code.</p><script>window.close()<\/script></body></html>");
215
+ clearTimeout(authTimeout);
100
216
  callbackServer.close();
101
217
  resolve(tokenData);
102
218
  } catch (err) {
103
219
  res.writeHead(500, { "Content-Type": "text/html" });
104
- res.end(`<html><body><h1>Token exchange failed</h1><p>${err instanceof Error ? err.message : String(err)}</p></body></html>`);
220
+ res.end(`<html><body><h1>Token exchange failed</h1><p>${escapeHtml(err instanceof Error ? err.message : String(err))}</p></body></html>`);
221
+ clearTimeout(authTimeout);
105
222
  callbackServer.close();
106
223
  reject(err);
107
224
  }
108
225
  });
109
226
  callbackServer.listen(config.callbackPort ?? DEFAULT_CALLBACK_PORT, () => {
110
- const redirectUri = `http://localhost:${callbackServer.address().port}/callback`;
227
+ const port = callbackServer.address().port;
228
+ const redirectUri = `http://localhost:${port}/callback`;
111
229
  const authUrl = `${authorizeUrl}?${new URLSearchParams({
112
230
  response_type: "code",
113
231
  client_id: oauthClientId,
@@ -116,19 +234,42 @@ const authenticateViaBrowser = (config) => {
116
234
  code_challenge: codeChallenge,
117
235
  code_challenge_method: "S256"
118
236
  }).toString()}`;
237
+ logger.debug("oauth_callback_listening", {
238
+ port,
239
+ redirectUri
240
+ });
241
+ logger.info("oauth_browser_opening");
242
+ logger.debug("oauth_authorize_url", { url: authUrl });
119
243
  console.error(`Opening browser for Zendesk authentication...`);
120
244
  console.error(`If the browser doesn't open, visit: ${authUrl}`);
121
- open(authUrl).catch(() => {});
245
+ open(authUrl).then(() => {
246
+ logger.debug("oauth_browser_opened");
247
+ }).catch((err) => {
248
+ logger.error("oauth_browser_open_failed", {
249
+ error: err instanceof Error ? err.message : String(err),
250
+ errorCode: err?.code,
251
+ platform: process.platform,
252
+ release: release(),
253
+ isWsl: detectWsl(),
254
+ hasSystemRoot: Boolean(process.env["SYSTEMROOT"]),
255
+ hasWindir: Boolean(process.env["WINDIR"]),
256
+ hasComSpec: Boolean(process.env["ComSpec"]),
257
+ hasPath: Boolean(process.env["PATH"]),
258
+ hasDisplay: Boolean(process.env["DISPLAY"])
259
+ });
260
+ });
122
261
  });
123
- setTimeout(() => {
262
+ authTimeout = setTimeout(() => {
263
+ logger.error("oauth_timeout", { timeoutMs: AUTH_TIMEOUT_MS });
124
264
  callbackServer.close();
125
265
  reject(/* @__PURE__ */ new Error("OAuth authentication timed out (5 min). Please try again."));
126
- }, 300 * 1e3).unref();
266
+ }, AUTH_TIMEOUT_MS);
267
+ authTimeout.unref();
127
268
  });
128
269
  };
129
270
  //#endregion
130
271
  //#region src/auth/token-store.ts
131
- const createTokenStore = (config) => {
272
+ const createTokenStore = (config, logger = silentLogger) => {
132
273
  let token;
133
274
  let authPromise;
134
275
  const setToken = (accessToken, refreshToken) => {
@@ -138,22 +279,29 @@ const createTokenStore = (config) => {
138
279
  };
139
280
  };
140
281
  const ensureToken = async () => {
141
- if (token) return token;
142
- if (!authPromise) authPromise = authenticateViaBrowser({
143
- subdomain: config.subdomain,
144
- oauthClientId: config.oauthClientId
145
- }).then((result) => {
146
- const stored = {
147
- accessToken: result.access_token,
148
- refreshToken: result.refresh_token
149
- };
150
- token = stored;
151
- authPromise = void 0;
152
- return stored;
153
- }).catch((err) => {
154
- authPromise = void 0;
155
- throw err;
156
- });
282
+ if (token) {
283
+ logger.debug("oauth_token_cache_hit");
284
+ return token;
285
+ }
286
+ if (!authPromise) {
287
+ logger.info("oauth_auth_start");
288
+ authPromise = authenticateViaBrowser({
289
+ subdomain: config.subdomain,
290
+ oauthClientId: config.oauthClientId
291
+ }, logger).then((result) => {
292
+ const stored = {
293
+ accessToken: result.access_token,
294
+ refreshToken: result.refresh_token
295
+ };
296
+ token = stored;
297
+ authPromise = void 0;
298
+ return stored;
299
+ }).catch((err) => {
300
+ authPromise = void 0;
301
+ logger.warn("oauth_auth_failed", { error: err instanceof Error ? err.message : String(err) });
302
+ throw err;
303
+ });
304
+ }
157
305
  return authPromise;
158
306
  };
159
307
  const getToken = async () => {
@@ -382,7 +530,7 @@ const textOf = (html) => {
382
530
  return cheerio.load(`<div>${html}</div>`, null, false)("div").first().text();
383
531
  };
384
532
  const parseSections = (html) => {
385
- if (!html || !html.trim()) return [];
533
+ if (!html?.trim()) return [];
386
534
  const $ = cheerio.load(html, null, false);
387
535
  const children = $.root().contents().toArray();
388
536
  const introParts = [];
@@ -1799,6 +1947,45 @@ const createAllTools = (ctx) => [
1799
1947
  ...createUserTools(ctx)
1800
1948
  ];
1801
1949
  //#endregion
1950
+ //#region src/utils/package-info.ts
1951
+ const FALLBACK = {
1952
+ name: "@fruggr/zendesk-mcp-server",
1953
+ version: "0.0.0"
1954
+ };
1955
+ /**
1956
+ * Read `name`/`version` from the package's own package.json at runtime instead
1957
+ * of hardcoding them. Walks up from this module to the nearest package.json,
1958
+ * which resolves correctly both when bundled (`dist/index.js` → repo root) and
1959
+ * from source/tests (`src/` has no package.json, so the root is found). Reading
1960
+ * at runtime (not inlining at build) matters because semantic-release bumps the
1961
+ * version into package.json before publishing, after the build step.
1962
+ */
1963
+ let cached;
1964
+ const readPackageInfo = () => {
1965
+ if (cached) return cached;
1966
+ let dir = dirname(fileURLToPath(import.meta.url));
1967
+ for (let depth = 0; depth < 8; depth++) {
1968
+ try {
1969
+ const raw = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
1970
+ if (raw && typeof raw === "object") {
1971
+ const pkg = raw;
1972
+ if (typeof pkg.name === "string" && typeof pkg.version === "string") {
1973
+ cached = {
1974
+ name: pkg.name,
1975
+ version: pkg.version
1976
+ };
1977
+ return cached;
1978
+ }
1979
+ }
1980
+ } catch {}
1981
+ const parent = dirname(dir);
1982
+ if (parent === dir) break;
1983
+ dir = parent;
1984
+ }
1985
+ cached = FALLBACK;
1986
+ return cached;
1987
+ };
1988
+ //#endregion
1802
1989
  //#region src/server.ts
1803
1990
  const NAMESPACE_LABELS = {
1804
1991
  tickets: {
@@ -1840,11 +2027,13 @@ const registerProxyTool = (server, toolName, title, tools, handlerMap) => {
1840
2027
  return def.handler(validated);
1841
2028
  });
1842
2029
  };
1843
- const createMcpServer = (config, getToken) => {
2030
+ const createMcpServer = (config, getToken, logger = silentLogger) => {
2031
+ const pkg = readPackageInfo();
1844
2032
  const server = new McpServer({
1845
- name: "@digital4better/zendesk-mcp-server",
1846
- version: "0.1.0"
1847
- });
2033
+ name: pkg.name,
2034
+ version: pkg.version
2035
+ }, { capabilities: { logging: {} } });
2036
+ logger.attachServer(server);
1848
2037
  const filteredTools = filterTools(createAllTools({
1849
2038
  subdomain: config.subdomain,
1850
2039
  getToken
@@ -1876,28 +2065,32 @@ const createMcpServer = (config, getToken) => {
1876
2065
  registerProxyTool(server, "zendesk", "Zendesk", filteredTools, handlerMap);
1877
2066
  break;
1878
2067
  }
1879
- console.error(`Registered ${filteredTools.length} tools in ${config.mode} mode`);
2068
+ logger.info("tools_registered", {
2069
+ count: filteredTools.length,
2070
+ mode: config.mode
2071
+ });
1880
2072
  return server;
1881
2073
  };
1882
2074
  //#endregion
1883
2075
  //#region src/transports/stdio.ts
1884
- const startStdioTransport = async (server) => {
2076
+ const startStdioTransport = async (server, logger = silentLogger) => {
1885
2077
  const transport = new StdioServerTransport();
1886
2078
  await server.connect(transport);
1887
- console.error("Zendesk MCP server running via stdio");
2079
+ logger.info("stdio_transport_ready");
1888
2080
  };
1889
2081
  //#endregion
1890
2082
  //#region src/index.ts
1891
2083
  const main = async () => {
1892
2084
  const config = loadConfig();
2085
+ const logger = createLogger(config.logLevel);
1893
2086
  if (config.zendeskEmail && config.zendeskApiToken) {
1894
2087
  const staticToken = buildBasicAuthHeader(config.zendeskEmail, config.zendeskApiToken);
1895
2088
  const getToken = () => staticToken;
1896
- await startStdioTransport(createMcpServer(config, getToken));
2089
+ await startStdioTransport(createMcpServer(config, getToken, logger), logger);
1897
2090
  } else await startStdioTransport(createMcpServer(config, createTokenStore({
1898
2091
  subdomain: config.subdomain,
1899
2092
  oauthClientId: config.oauthClientId
1900
- }).getToken));
2093
+ }, logger).getToken, logger), logger);
1901
2094
  };
1902
2095
  main().catch((error) => {
1903
2096
  console.error("Fatal error:", error);