@fruggr/zendesk-mcp-server 2.11.0 → 2.12.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
@@ -5,7 +5,7 @@ import { createServer } from "node:http";
5
5
  import { homedir, release } from "node:os";
6
6
  import open from "open";
7
7
  import { dirname, join } from "node:path";
8
- import { fileURLToPath } from "node:url";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import * as z from "zod/v4";
10
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
11
  import * as cheerio from "cheerio";
@@ -19,8 +19,8 @@ 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";
23
22
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
23
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
24
24
  //#region src/utils/logger.ts
25
25
  const SEVERITY = {
26
26
  debug: 0,
@@ -600,6 +600,14 @@ const ConfigSchema = z.object({
600
600
  * `help_center` namespace itself is active.
601
601
  */
602
602
  topology: z.boolean().default(true),
603
+ /**
604
+ * Dev-only (stdio): expose the `reload_tools` tool, which re-imports the tool
605
+ * modules from source and re-registers them on the live session on demand, so
606
+ * tool code edited during a dev cycle takes effect without a restart. CLI-only
607
+ * and off by default — it has no place in a deployed server. Fuller notes in
608
+ * the "Dev mode" section of docs/configuration.md.
609
+ */
610
+ dev: z.boolean().default(false),
603
611
  transport: Transport,
604
612
  host: z.string().min(1),
605
613
  port: z.number().int().min(0).max(65535),
@@ -631,6 +639,7 @@ const parseCliArgs = (args) => {
631
639
  i++;
632
640
  } else if (arg === "--read-only") result.readOnly = true;
633
641
  else if (arg === "--no-topology") result.topology = false;
642
+ else if (arg === "--dev") result.dev = true;
634
643
  else if (arg === "--namespace" && next) {
635
644
  result.namespaces = result.namespaces ?? [];
636
645
  result.namespaces.push(next);
@@ -689,6 +698,7 @@ const loadConfig = (argv = process.argv.slice(2)) => {
689
698
  namespaces: cli.namespaces,
690
699
  tools: cli.tools,
691
700
  topology: cli.topology ?? true,
701
+ dev: cli.dev ?? false,
692
702
  transport,
693
703
  host,
694
704
  port,
@@ -960,6 +970,89 @@ const formatComment = (comment) => {
960
970
  lines.push("", comment.body);
961
971
  return lines.join("\n");
962
972
  };
973
+ const formatTagDiff = (before, after) => {
974
+ const b = new Set(Array.isArray(before) ? before.map(String) : []);
975
+ const a = new Set(Array.isArray(after) ? after.map(String) : []);
976
+ const added = [...a].filter((t) => !b.has(t)).map((t) => `+${t}`);
977
+ const removed = [...b].filter((t) => !a.has(t)).map((t) => `-${t}`);
978
+ return added.length + removed.length === 0 ? null : `- **tags**: ${[...added, ...removed].join(", ")}`;
979
+ };
980
+ const AUDIT_ENTITY_FIELDS = {
981
+ assignee_id: "user",
982
+ requester_id: "user",
983
+ submitter_id: "user",
984
+ group_id: "group"
985
+ };
986
+ const AUDIT_CREATE_FIELDS = /* @__PURE__ */ new Set([
987
+ "status",
988
+ "priority",
989
+ "type",
990
+ "assignee_id",
991
+ "group_id",
992
+ "subject",
993
+ "tags"
994
+ ]);
995
+ const AUDIT_FIELD_LABELS = {
996
+ assignee_id: "assignee",
997
+ requester_id: "requester",
998
+ submitter_id: "submitter",
999
+ group_id: "group"
1000
+ };
1001
+ const withName = (id, names) => {
1002
+ const n = Number(id);
1003
+ if (n === -1) return "System (-1)";
1004
+ const name = names.get(n);
1005
+ return name ? `${name} (${id})` : String(id);
1006
+ };
1007
+ const renderAuditValue = (field, value, names) => {
1008
+ if (value === null || value === void 0 || value === "") return "";
1009
+ const entity = AUDIT_ENTITY_FIELDS[field];
1010
+ if (entity === "user") return withName(value, names.users);
1011
+ if (entity === "group") return withName(value, names.groups);
1012
+ if (typeof value === "object" && !Array.isArray(value) && "minutes" in value) {
1013
+ const { minutes } = value;
1014
+ if (typeof minutes === "number") return `${minutes} min`;
1015
+ }
1016
+ return formatFieldValue(value);
1017
+ };
1018
+ const renderCreateEvent = (event, names) => {
1019
+ const field = event.field_name;
1020
+ if (!field || !AUDIT_CREATE_FIELDS.has(field)) return null;
1021
+ if (field === "tags") {
1022
+ const tags = Array.isArray(event.value) ? event.value.map(String) : [];
1023
+ return tags.length > 0 ? `- **tags**: ${tags.join(", ")}` : null;
1024
+ }
1025
+ const value = renderAuditValue(field, event.value, names);
1026
+ return value === "" ? null : `- **${AUDIT_FIELD_LABELS[field] ?? field}**: ${value}`;
1027
+ };
1028
+ const renderChangeEvent = (event, names) => {
1029
+ const field = event.field_name;
1030
+ if (!field) return null;
1031
+ if (field === "tags") return formatTagDiff(event.previous_value, event.value);
1032
+ const after = renderAuditValue(field, event.value, names);
1033
+ const before = renderAuditValue(field, event.previous_value, names);
1034
+ if (before === after) return null;
1035
+ return `- **${AUDIT_FIELD_LABELS[field] ?? field}**: ${before || "(none)"} → ${after || "(none)"}`;
1036
+ };
1037
+ const renderAuditEvent = (event, names) => {
1038
+ switch (event.type) {
1039
+ case "Create": return renderCreateEvent(event, names);
1040
+ case "Change": return renderChangeEvent(event, names);
1041
+ case "Comment":
1042
+ case "VoiceComment": return `- ${event.public === false ? "Internal note" : "Public comment"} added`;
1043
+ case "CommentPrivacyChange": return "- Comment visibility changed";
1044
+ case "FollowersChange": return "- Followers changed";
1045
+ case "EmailCcChange": return "- Email CCs changed";
1046
+ case "SatisfactionRating": return "- Satisfaction rating recorded";
1047
+ default: return null;
1048
+ }
1049
+ };
1050
+ const formatAudit = (audit, names) => {
1051
+ const lines = audit.events.map((e) => renderAuditEvent(e, names)).filter((l) => l !== null);
1052
+ if (lines.length === 0) return null;
1053
+ const channel = audit.via?.channel ? ` via ${audit.via.channel}` : "";
1054
+ return [`### ${audit.created_at} — ${withName(audit.author_id, names.users)}${channel}`, ...lines].join("\n");
1055
+ };
963
1056
  const formatUser = (user) => [
964
1057
  `## ${user.name} (${user.id})`,
965
1058
  `- **Email**: ${user.email}`,
@@ -2172,6 +2265,44 @@ const hydrateViewTickets = async (subdomain, token, ids) => {
2172
2265
  const byId = new Map((tickets ?? []).map((t) => [t.id, t]));
2173
2266
  return ids.map((id) => byId.get(id)).filter((t) => t !== void 0);
2174
2267
  };
2268
+ const collectAuditIds = (audits) => {
2269
+ const userIds = /* @__PURE__ */ new Set();
2270
+ const groupIds = /* @__PURE__ */ new Set();
2271
+ const addId = (set, raw) => {
2272
+ const n = Number(raw);
2273
+ if (Number.isInteger(n) && n > 0) set.add(n);
2274
+ };
2275
+ for (const audit of audits) {
2276
+ addId(userIds, audit.author_id);
2277
+ for (const event of audit.events) {
2278
+ if (event.type !== "Change" && event.type !== "Create") continue;
2279
+ const entity = event.field_name ? AUDIT_ENTITY_FIELDS[event.field_name] : void 0;
2280
+ if (!entity) continue;
2281
+ const set = entity === "user" ? userIds : groupIds;
2282
+ addId(set, event.value);
2283
+ addId(set, event.previous_value);
2284
+ }
2285
+ }
2286
+ return {
2287
+ userIds: [...userIds],
2288
+ groupIds: [...groupIds]
2289
+ };
2290
+ };
2291
+ const resolveAuditNames = async (subdomain, token, userIds, groupIds) => {
2292
+ const resolve = async (path, key, ids) => {
2293
+ const map = /* @__PURE__ */ new Map();
2294
+ for (const batch of chunk(ids, 100)) try {
2295
+ const res = await zendeskGet(subdomain, token, path, { ids: batch.join(",") });
2296
+ for (const entity of res[key] ?? []) map.set(entity.id, entity.name);
2297
+ } catch {}
2298
+ return map;
2299
+ };
2300
+ const [users, groups] = await Promise.all([resolve("/users/show_many", "users", userIds), resolve("/groups/show_many", "groups", groupIds)]);
2301
+ return {
2302
+ users,
2303
+ groups
2304
+ };
2305
+ };
2175
2306
  const DIFF_SKIP_KEYS = /* @__PURE__ */ new Set([
2176
2307
  "comment",
2177
2308
  "fields",
@@ -2193,13 +2324,6 @@ const diffLine = (label, before, after) => {
2193
2324
  const a = shownValue(after);
2194
2325
  return b === a ? null : `- **${label}**: ${b} → ${a}`;
2195
2326
  };
2196
- const formatTagDiff = (before, after) => {
2197
- const b = new Set(Array.isArray(before) ? before.map(String) : []);
2198
- const a = new Set(Array.isArray(after) ? after.map(String) : []);
2199
- const added = [...a].filter((t) => !b.has(t)).map((t) => `+${t}`);
2200
- const removed = [...b].filter((t) => !a.has(t)).map((t) => `-${t}`);
2201
- return added.length + removed.length === 0 ? null : `- **tags**: ${[...added, ...removed].join(", ")}`;
2202
- };
2203
2327
  const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
2204
2328
  const after = result?.ticket ?? {};
2205
2329
  const beforeObj = before ?? {};
@@ -2259,7 +2383,7 @@ const createTicketTools = (ctx) => {
2259
2383
  namespace: "tickets",
2260
2384
  readOnly: true,
2261
2385
  title: "Get Zendesk Ticket",
2262
- description: "Retrieve a Zendesk ticket by ID, including its live SLA state (per-metric stage and breach countdown) when an SLA policy applies, plus its comments if requested. Returns ticket details (subject, status, priority, assignee, tags, description) and optionally all comments/internal notes. The per-ticket Show endpoint exposes no SLA, so the SLA block is resolved via a scoped search and may be absent for a very high-volume requester or a just-updated ticket; SLA targets and policy conditions live in list_sla_policies.",
2386
+ description: "Retrieve a Zendesk ticket by ID, including its live SLA state (per-metric stage and breach countdown) when an SLA policy applies, plus its comments if requested. Returns ticket details (subject, status, priority, assignee, tags, description) and optionally all comments/internal notes. The per-ticket Show endpoint exposes no SLA, so the SLA block is resolved via a scoped search and may be absent for a very high-volume requester or a just-updated ticket; SLA targets and policy conditions live in list_sla_policies. This returns the ticket as it stands now; for the history of changes behind that state (who changed what, and when), use get_ticket_history.",
2263
2387
  inputSchema: z.object({
2264
2388
  ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket to fetch. Obtain it from search_tickets or list_tickets."),
2265
2389
  include_comments: z.boolean().default(false).describe("When true, appends the full public comment and internal note thread to the response. Defaults to false to keep the payload small; enable it when you need the conversation, not just the ticket fields.")
@@ -2285,6 +2409,51 @@ const createTicketTools = (ctx) => {
2285
2409
  }] };
2286
2410
  }
2287
2411
  },
2412
+ {
2413
+ name: "get_ticket_history",
2414
+ namespace: "tickets",
2415
+ readOnly: true,
2416
+ title: "Get Zendesk Ticket History",
2417
+ description: "Read a ticket's change history — its audit trail — as a chronological, oldest-first timeline of who changed what and when. Each entry shows the actor (name and id) and the channel, then the field changes that update carried (status, priority, assignee, group, tags, custom fields) as before → after, with assignee/requester/group ids resolved to names. Comments appear as one-line presence markers (public comment vs internal note added), not their text — fetch the bodies with get_ticket(include_comments=true). Purely system-generated notification events (trigger emails, collaborator/CC notifications, pushes) are filtered out — note this filters notification delivery, not CC-list edits, which are shown as changes — and an update carrying only such events produces no entry, so the timeline stays a readable narrative rather than a raw log. Use it to answer \"what happened on this ticket?\", \"why was it reassigned?\" or \"when did it go to pending?\", reading oldest-first so the founding context is not missed. Read-only, and cursor-paginated oldest-first: pass the returned cursor to page a long-lived ticket toward its most recent changes.",
2418
+ inputSchema: z.object({
2419
+ ticket_id: z.number().int().describe("Ticket ID — the numeric id of the ticket whose change history to read. Obtain it from search_tickets or list_tickets."),
2420
+ page_size: z.number().int().min(1).max(100).default(100).describe("Audits (ticket updates) per page (1-100, default 100). Each audit is one update to the ticket and may expand to several change lines; audits carrying only system events are dropped, so a page can render fewer entries than this."),
2421
+ cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page. The timeline is ordered oldest-first, so paging forward moves toward the most recent changes.")
2422
+ }),
2423
+ annotations: {
2424
+ readOnlyHint: true,
2425
+ destructiveHint: false,
2426
+ idempotentHint: true,
2427
+ openWorldHint: true
2428
+ },
2429
+ handler: async (params) => {
2430
+ const { ticket_id, page_size, cursor } = params;
2431
+ const token = await getToken();
2432
+ let response;
2433
+ try {
2434
+ response = await zendeskGet(subdomain, token, `/tickets/${ticket_id}/audits`, buildCursorParams(page_size, cursor));
2435
+ } catch (error) {
2436
+ if (error instanceof ZendeskApiError && error.status === 403) throw new Error("get_ticket_history reads the Ticket Audits API (GET /tickets/{id}/audits), which Zendesk gates behind the global 'read' OAuth scope. The current token lacks it (HTTP 403) -- a narrower scope such as tickets:read can read tickets and comments but not their audit history. Re-authenticate with the global read scope to use this tool.", { cause: error });
2437
+ throw error;
2438
+ }
2439
+ const audits = response.audits ?? [];
2440
+ const meta = extractPaginationMeta(response, audits.length);
2441
+ const { userIds, groupIds } = collectAuditIds(audits);
2442
+ const names = await resolveAuditNames(subdomain, token, userIds, groupIds);
2443
+ const blocks = audits.map((audit) => formatAudit(audit, names)).filter((block) => block !== null);
2444
+ if (blocks.length === 0) return { content: [{
2445
+ type: "text",
2446
+ text: meta.has_more ? `No changes to show on this page of ticket #${ticket_id}'s history (system events only). More available (cursor: ${meta.after_cursor}).` : `No change history to show for ticket #${ticket_id}.`
2447
+ }] };
2448
+ return { content: [{
2449
+ type: "text",
2450
+ text: `# Change history for ticket #${ticket_id}\n\n${formatList(blocks, (block) => block, {
2451
+ ...meta,
2452
+ count: blocks.length
2453
+ })}`
2454
+ }] };
2455
+ }
2456
+ },
2288
2457
  {
2289
2458
  name: "get_ticket_attachments",
2290
2459
  namespace: "tickets",
@@ -3047,7 +3216,7 @@ const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnaut
3047
3216
  const annotations = aggregateAnnotations(tools);
3048
3217
  const prefix = readOnlyMode ? "[RO] " : "";
3049
3218
  const dispatch = buildProxyDispatch(tools, onUnauthorized);
3050
- server.registerTool(toolName, {
3219
+ return server.registerTool(toolName, {
3051
3220
  title,
3052
3221
  description: `${prefix}${title}. Specify the operation and its parameters.\n\nAvailable operations:\n${operationList}`,
3053
3222
  inputSchema: {
@@ -3057,7 +3226,14 @@ const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnaut
3057
3226
  annotations
3058
3227
  }, async (args) => dispatch(args));
3059
3228
  };
3060
- const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized) => {
3229
+ /**
3230
+ * Build the bare `McpServer` — identity, capabilities, `instructions` and the
3231
+ * logging sink — with no tools or resources registered yet. Split out from
3232
+ * `createMcpServer` so the dev reload (`dev/reload.ts`) can keep this
3233
+ * long-lived shell (and its transport/session) alive while swapping the toolset
3234
+ * underneath it via `registerToolset`.
3235
+ */
3236
+ const createServerShell = (config, logger = silentLogger) => {
3061
3237
  const pkg = readPackageInfo();
3062
3238
  const instructions = buildInstructions(config);
3063
3239
  const server = new McpServer({
@@ -3068,54 +3244,231 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
3068
3244
  ...instructions ? { instructions } : {}
3069
3245
  });
3070
3246
  logger.attachServer(server);
3071
- const filteredTools = filterTools(createAllTools({
3072
- subdomain: config.subdomain,
3073
- getToken
3074
- }), {
3247
+ return server;
3248
+ };
3249
+ /**
3250
+ * Registers one generation of the toolset (mode/filters applied) plus the
3251
+ * optional topology resource onto an existing server, and returns a handle
3252
+ * whose `dispose()` removes exactly what this call added. When the SDK server
3253
+ * is already connected, each `registerTool`/`remove` emits `list_changed`, so a
3254
+ * dispose-then-register cycle hot-swaps the exposed tools in place — this is the
3255
+ * mechanism the dev reload (`dev/reload.ts`) uses to reflect edited tool code
3256
+ * without dropping the transport. `tools` is passed in (not built here) so the
3257
+ * reload path can hand over freshly re-imported definitions.
3258
+ */
3259
+ const registerToolset = (server, { config, getToken, onUnauthorized, logger = silentLogger }, tools) => {
3260
+ const registered = [];
3261
+ const dispose = () => {
3262
+ for (const handle of registered) handle.remove();
3263
+ };
3264
+ const filteredTools = filterTools(tools, {
3075
3265
  readOnly: config.readOnly,
3076
3266
  namespaces: config.namespaces,
3077
3267
  tools: config.tools
3078
3268
  });
3079
- switch (config.mode) {
3080
- case "all":
3081
- for (const tool of filteredTools) server.registerTool(tool.name, {
3082
- title: tool.title,
3083
- description: tool.description,
3084
- inputSchema: tool.inputSchema.strict(),
3085
- annotations: tool.annotations
3086
- }, async (params) => runHandler(tool, params, onUnauthorized));
3087
- break;
3088
- case "namespace": {
3089
- const grouped = groupByNamespace(filteredTools);
3090
- for (const [namespace, tools] of grouped) {
3091
- const label = NAMESPACE_LABELS[namespace];
3092
- if (label) registerProxyTool(server, label.toolName, label.title, tools, config.readOnly, onUnauthorized);
3269
+ try {
3270
+ switch (config.mode) {
3271
+ case "all":
3272
+ for (const tool of filteredTools) registered.push(server.registerTool(tool.name, {
3273
+ title: tool.title,
3274
+ description: tool.description,
3275
+ inputSchema: tool.inputSchema.strict(),
3276
+ annotations: tool.annotations
3277
+ }, async (params) => runHandler(tool, params, onUnauthorized)));
3278
+ break;
3279
+ case "namespace": {
3280
+ const grouped = groupByNamespace(filteredTools);
3281
+ for (const [namespace, nsTools] of grouped) {
3282
+ const label = NAMESPACE_LABELS[namespace];
3283
+ if (label) registered.push(registerProxyTool(server, label.toolName, label.title, nsTools, config.readOnly, onUnauthorized));
3284
+ }
3285
+ break;
3093
3286
  }
3094
- break;
3287
+ case "single":
3288
+ registered.push(registerProxyTool(server, "zendesk", "Zendesk", filteredTools, config.readOnly, onUnauthorized));
3289
+ break;
3095
3290
  }
3096
- case "single":
3097
- registerProxyTool(server, "zendesk", "Zendesk", filteredTools, config.readOnly, onUnauthorized);
3098
- break;
3099
- }
3100
- if (helpCenterContextEnabled(config)) {
3101
- const topology = createTopologyProvider(getToken, config.subdomain, onUnauthorized);
3102
- server.registerResource("help-center-topology", TOPOLOGY_RESOURCE_URI, {
3103
- title: "Zendesk Help Center topology",
3104
- description: "Active locales, category → section tree, visibility segments, permission groups, and your role. Read before creating or editing content.",
3105
- mimeType: "text/markdown"
3106
- }, async (uri) => ({ contents: [{
3107
- uri: uri.toString(),
3108
- mimeType: "text/markdown",
3109
- text: await topology.read()
3110
- }] }));
3291
+ if (helpCenterContextEnabled(config)) {
3292
+ const topology = createTopologyProvider(getToken, config.subdomain, onUnauthorized);
3293
+ registered.push(server.registerResource("help-center-topology", TOPOLOGY_RESOURCE_URI, {
3294
+ title: "Zendesk Help Center topology",
3295
+ description: "Active locales, category → section tree, visibility segments, permission groups, and your role. Read before creating or editing content.",
3296
+ mimeType: "text/markdown"
3297
+ }, async (uri) => ({ contents: [{
3298
+ uri: uri.toString(),
3299
+ mimeType: "text/markdown",
3300
+ text: await topology.read()
3301
+ }] })));
3302
+ }
3303
+ } catch (err) {
3304
+ dispose();
3305
+ throw err;
3111
3306
  }
3112
3307
  logger.info("tools_registered", {
3113
3308
  count: filteredTools.length,
3114
3309
  mode: config.mode
3115
3310
  });
3311
+ return {
3312
+ count: filteredTools.length,
3313
+ dispose
3314
+ };
3315
+ };
3316
+ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized) => {
3317
+ const server = createServerShell(config, logger);
3318
+ const tools = createAllTools({
3319
+ subdomain: config.subdomain,
3320
+ getToken
3321
+ });
3322
+ registerToolset(server, {
3323
+ config,
3324
+ getToken,
3325
+ onUnauthorized,
3326
+ logger
3327
+ }, tools);
3116
3328
  return server;
3117
3329
  };
3118
3330
  //#endregion
3331
+ //#region src/transports/stdio.ts
3332
+ const startStdioTransport = async (server, logger = silentLogger) => {
3333
+ const transport = new StdioServerTransport();
3334
+ await server.connect(transport);
3335
+ logger.info("stdio_transport_ready");
3336
+ };
3337
+ //#endregion
3338
+ //#region src/dev/reload.ts
3339
+ const toolsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "tools");
3340
+ const TOOL_MODULES = [
3341
+ {
3342
+ file: "tickets.ts",
3343
+ factory: "createTicketTools"
3344
+ },
3345
+ {
3346
+ file: "search.ts",
3347
+ factory: "createSearchTools"
3348
+ },
3349
+ {
3350
+ file: "help-center.ts",
3351
+ factory: "createHelpCenterTools"
3352
+ },
3353
+ {
3354
+ file: "users.ts",
3355
+ factory: "createUserTools"
3356
+ }
3357
+ ];
3358
+ /**
3359
+ * Re-import every leaf tool-factory module with a fresh cache-busting query so
3360
+ * the running process sees edited tool code, then recompose the definitions
3361
+ * exactly as `createAllTools` does. A per-call `randomUUID()` nonce keeps the
3362
+ * import specifier unique (Node caches modules by specifier, so a repeated
3363
+ * query would serve stale code) without any shared module state.
3364
+ */
3365
+ const loadFreshTools = async (ctx) => {
3366
+ const nonce = randomUUID();
3367
+ return (await Promise.all(TOOL_MODULES.map(async ({ file, factory }) => {
3368
+ const create = (await import(`${pathToFileURL(join(toolsDir, file)).href}?v=${nonce}`))[factory];
3369
+ /* v8 ignore next 2 -- defensive guard: only hit if a factory export is renamed */
3370
+ if (!create) throw new Error(`Tool module ${file} has no export ${factory}`);
3371
+ return create(ctx);
3372
+ }))).flat();
3373
+ };
3374
+ /**
3375
+ * A server shell plus a `reload()` that swaps in freshly re-imported tool code
3376
+ * over the live session and returns the new tool count. Split from
3377
+ * `startDevServer` so the reconciliation can be exercised without a transport:
3378
+ * the initial toolset is registered from the static import (fast, and reload
3379
+ * only matters after an edit); `reload()` disposes the current generation and
3380
+ * registers a fresh one.
3381
+ */
3382
+ const createReloadableServer = (config, getToken, logger = silentLogger, onUnauthorized, loadTools = loadFreshTools) => {
3383
+ const server = createServerShell(config, logger);
3384
+ const ctx = {
3385
+ subdomain: config.subdomain,
3386
+ getToken
3387
+ };
3388
+ const params = {
3389
+ config,
3390
+ getToken,
3391
+ onUnauthorized,
3392
+ logger
3393
+ };
3394
+ let currentTools = createAllTools(ctx);
3395
+ let current = registerToolset(server, params, currentTools);
3396
+ const reload = async () => {
3397
+ const tools = await loadTools(ctx);
3398
+ current.dispose();
3399
+ try {
3400
+ current = registerToolset(server, params, tools);
3401
+ currentTools = tools;
3402
+ } catch (err) {
3403
+ try {
3404
+ current = registerToolset(server, params, currentTools);
3405
+ } catch (rollbackErr) {
3406
+ logger.error("tools_rollback_failed", { error: rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr) });
3407
+ }
3408
+ throw err;
3409
+ }
3410
+ return current.count;
3411
+ };
3412
+ return {
3413
+ server,
3414
+ reload
3415
+ };
3416
+ };
3417
+ /**
3418
+ * Register the dev-only `reload_tools` meta-tool on the server. It stays
3419
+ * registered across reloads (it is NOT part of the disposable toolset
3420
+ * generation), so it can always be called again. Its handler triggers a reload
3421
+ * and reports the outcome; a failed reload surfaces as a tool error while the
3422
+ * previous generation stays live.
3423
+ */
3424
+ const registerReloadTool = (server, reload, logger = silentLogger) => {
3425
+ server.registerTool("reload_tools", {
3426
+ title: "Reload tools from source (dev)",
3427
+ description: "Dev only. Re-imports the Zendesk tool modules from source and re-registers them on this live session, so tool code you just edited takes effect without restarting the server or reconnecting the client. Call it once at the end of an edit cycle, before testing your changes; the refreshed tool list is announced via tools/list_changed. Only the tool modules (tickets, search, help_center, users) are reloaded — edits to shared infrastructure (HTTP client, shared definitions, server wiring) still require a full restart. Takes no arguments and makes no Zendesk API calls.",
3428
+ inputSchema: z.object({}).strict(),
3429
+ annotations: {
3430
+ readOnlyHint: false,
3431
+ destructiveHint: false,
3432
+ idempotentHint: true,
3433
+ openWorldHint: false
3434
+ }
3435
+ }, async () => {
3436
+ try {
3437
+ const count = await reload();
3438
+ logger.info("tools_reloaded", { count });
3439
+ return { content: [{
3440
+ type: "text",
3441
+ text: `Reloaded ${count} tool(s) from source. The updated tool list is now live (tools/list_changed sent).`
3442
+ }] };
3443
+ } catch (err) {
3444
+ const message = err instanceof Error ? err.message : String(err);
3445
+ logger.error("tools_reload_failed", { error: message });
3446
+ return {
3447
+ isError: true,
3448
+ content: [{
3449
+ type: "text",
3450
+ text: `Reload failed; the previously loaded tools are still live. Fix the error and call reload_tools again: ${message}`
3451
+ }]
3452
+ };
3453
+ }
3454
+ });
3455
+ };
3456
+ /**
3457
+ * Start the stdio server in dev mode: the normal toolset plus a persistent
3458
+ * `reload_tools` tool that hot-reloads edited tool code on demand. stdio only —
3459
+ * HTTP builds a per-session server per request, so there is no long-lived
3460
+ * server to hot-swap.
3461
+ */
3462
+ /* v8 ignore start -- runtime bootstrap: binds the reload tool to a real stdio
3463
+ transport; the reload machinery it wires up is covered by dev-reload.test.ts */
3464
+ const startDevServer = async (config, getToken, logger = silentLogger, onUnauthorized) => {
3465
+ const { server, reload } = createReloadableServer(config, getToken, logger, onUnauthorized);
3466
+ registerReloadTool(server, reload, logger);
3467
+ await startStdioTransport(server, logger);
3468
+ logger.info("dev_mode_enabled");
3469
+ };
3470
+ /* v8 ignore stop */
3471
+ //#endregion
3119
3472
  //#region src/transports/http.ts
3120
3473
  const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
3121
3474
  "0.0.0.0",
@@ -3461,29 +3814,25 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
3461
3814
  };
3462
3815
  };
3463
3816
  //#endregion
3464
- //#region src/transports/stdio.ts
3465
- const startStdioTransport = async (server, logger = silentLogger) => {
3466
- const transport = new StdioServerTransport();
3467
- await server.connect(transport);
3468
- logger.info("stdio_transport_ready");
3469
- };
3470
- //#endregion
3471
3817
  //#region src/index.ts
3472
- const buildStdioServer = (config, logger) => {
3473
- const tokenStore = createTokenStore({
3474
- subdomain: config.subdomain,
3475
- oauthClientId: config.oauthClientId,
3476
- callbackPort: config.callbackPort
3477
- }, logger);
3478
- return createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
3479
- };
3818
+ const buildStdioTokenStore = (config, logger) => createTokenStore({
3819
+ subdomain: config.subdomain,
3820
+ oauthClientId: config.oauthClientId,
3821
+ callbackPort: config.callbackPort
3822
+ }, logger);
3480
3823
  const main = async () => {
3481
3824
  const config = loadConfig();
3482
3825
  const logger = createLogger(config.logLevel);
3483
3826
  if (config.transport === "stdio") {
3484
- await startStdioTransport(buildStdioServer(config, logger), logger);
3827
+ const tokenStore = buildStdioTokenStore(config, logger);
3828
+ if (config.dev) {
3829
+ await startDevServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
3830
+ return;
3831
+ }
3832
+ await startStdioTransport(createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate), logger);
3485
3833
  return;
3486
3834
  }
3835
+ if (config.dev) logger.warn("dev_mode_ignored_http");
3487
3836
  await startHttpTransport(config, logger);
3488
3837
  };
3489
3838
  main().catch((error) => {