@fruggr/zendesk-mcp-server 2.10.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 +551 -62
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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}`,
|
|
@@ -1000,6 +1093,11 @@ const formatTranslation = (translation) => [
|
|
|
1000
1093
|
].join("\n");
|
|
1001
1094
|
const formatCategory = (category) => `- **${category.name}** (${category.id}) — ${category.description || "No description"}`;
|
|
1002
1095
|
const formatSection = (section) => `- **${section.name}** (${section.id}) — Category: ${section.category_id} — ${section.description || "No description"}`;
|
|
1096
|
+
const formatView = (view, count) => {
|
|
1097
|
+
const countText = count ? ` — ${count.pretty} ticket(s)${count.fresh ? "" : " (count updating)"}` : "";
|
|
1098
|
+
const description = view.description ? ` — ${view.description}` : "";
|
|
1099
|
+
return `- **${view.title}** (id ${view.id})${countText}${description}`;
|
|
1100
|
+
};
|
|
1003
1101
|
const formatPermissionGroup = (group) => `- **${group.name}** (${group.id})${group.built_in ? " — Built-in" : ""}`;
|
|
1004
1102
|
const formatContentTag = (tag) => `- **${tag.name}** (${tag.id})`;
|
|
1005
1103
|
const formatLabel = (label) => `- **${label.name}** (${label.id})`;
|
|
@@ -2114,6 +2212,97 @@ const fetchTicketSla = async (subdomain, token, ticket) => {
|
|
|
2114
2212
|
return;
|
|
2115
2213
|
}
|
|
2116
2214
|
};
|
|
2215
|
+
const VIEW_COUNT_BATCH = 20;
|
|
2216
|
+
const chunk = (items, size) => {
|
|
2217
|
+
const groups = [];
|
|
2218
|
+
for (let i = 0; i < items.length; i += size) groups.push(items.slice(i, i + size));
|
|
2219
|
+
return groups;
|
|
2220
|
+
};
|
|
2221
|
+
const fetchViewCounts = async (subdomain, token, viewIds) => {
|
|
2222
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2223
|
+
for (const group of chunk(viewIds, VIEW_COUNT_BATCH)) try {
|
|
2224
|
+
const { view_counts } = await zendeskGet(subdomain, token, "/views/count_many", { ids: group.join(",") });
|
|
2225
|
+
for (const c of view_counts ?? []) counts.set(c.view_id, c);
|
|
2226
|
+
} catch {}
|
|
2227
|
+
return counts;
|
|
2228
|
+
};
|
|
2229
|
+
const resolveViewId = async (subdomain, token, view) => {
|
|
2230
|
+
if (typeof view === "number") return { id: view };
|
|
2231
|
+
const target = view.trim().toLowerCase();
|
|
2232
|
+
const available = [];
|
|
2233
|
+
let cursor;
|
|
2234
|
+
do {
|
|
2235
|
+
const response = await zendeskGet(subdomain, token, "/views", {
|
|
2236
|
+
active: "true",
|
|
2237
|
+
...buildCursorParams(100, cursor)
|
|
2238
|
+
});
|
|
2239
|
+
const views = response.views ?? [];
|
|
2240
|
+
const match = views.find((v) => v.title.trim().toLowerCase() === target);
|
|
2241
|
+
if (match) return { id: match.id };
|
|
2242
|
+
available.push(...views.map((v) => v.title));
|
|
2243
|
+
cursor = response.meta?.has_more ? response.meta.after_cursor ?? void 0 : void 0;
|
|
2244
|
+
} while (cursor);
|
|
2245
|
+
return { available };
|
|
2246
|
+
};
|
|
2247
|
+
const extractRowTicketId = (row) => {
|
|
2248
|
+
if (typeof row.ticket?.id === "number") return row.ticket.id;
|
|
2249
|
+
for (const key of ["id", "ticket_id"]) if (typeof row[key] === "number") return row[key];
|
|
2250
|
+
};
|
|
2251
|
+
const executeView = async (subdomain, token, viewId, opts) => {
|
|
2252
|
+
const params = buildCursorParams(opts.page_size, opts.cursor);
|
|
2253
|
+
if (opts.sort_by) params["sort_by"] = opts.sort_by;
|
|
2254
|
+
if (opts.sort_order) params["sort_order"] = opts.sort_order;
|
|
2255
|
+
const response = await zendeskGet(subdomain, token, `/views/${viewId}/execute`, params);
|
|
2256
|
+
const rows = response.rows ?? [];
|
|
2257
|
+
return {
|
|
2258
|
+
rows,
|
|
2259
|
+
meta: extractPaginationMeta(response, rows.length)
|
|
2260
|
+
};
|
|
2261
|
+
};
|
|
2262
|
+
const hydrateViewTickets = async (subdomain, token, ids) => {
|
|
2263
|
+
if (ids.length === 0) return [];
|
|
2264
|
+
const { tickets } = await zendeskGet(subdomain, token, "/tickets/show_many", { ids: ids.join(",") });
|
|
2265
|
+
const byId = new Map((tickets ?? []).map((t) => [t.id, t]));
|
|
2266
|
+
return ids.map((id) => byId.get(id)).filter((t) => t !== void 0);
|
|
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
|
+
};
|
|
2117
2306
|
const DIFF_SKIP_KEYS = /* @__PURE__ */ new Set([
|
|
2118
2307
|
"comment",
|
|
2119
2308
|
"fields",
|
|
@@ -2135,13 +2324,6 @@ const diffLine = (label, before, after) => {
|
|
|
2135
2324
|
const a = shownValue(after);
|
|
2136
2325
|
return b === a ? null : `- **${label}**: ${b} → ${a}`;
|
|
2137
2326
|
};
|
|
2138
|
-
const formatTagDiff = (before, after) => {
|
|
2139
|
-
const b = new Set(Array.isArray(before) ? before.map(String) : []);
|
|
2140
|
-
const a = new Set(Array.isArray(after) ? after.map(String) : []);
|
|
2141
|
-
const added = [...a].filter((t) => !b.has(t)).map((t) => `+${t}`);
|
|
2142
|
-
const removed = [...b].filter((t) => !a.has(t)).map((t) => `-${t}`);
|
|
2143
|
-
return added.length + removed.length === 0 ? null : `- **tags**: ${[...added, ...removed].join(", ")}`;
|
|
2144
|
-
};
|
|
2145
2327
|
const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
|
|
2146
2328
|
const after = result?.ticket ?? {};
|
|
2147
2329
|
const beforeObj = before ?? {};
|
|
@@ -2201,7 +2383,7 @@ const createTicketTools = (ctx) => {
|
|
|
2201
2383
|
namespace: "tickets",
|
|
2202
2384
|
readOnly: true,
|
|
2203
2385
|
title: "Get Zendesk Ticket",
|
|
2204
|
-
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.",
|
|
2205
2387
|
inputSchema: z.object({
|
|
2206
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."),
|
|
2207
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.")
|
|
@@ -2227,6 +2409,51 @@ const createTicketTools = (ctx) => {
|
|
|
2227
2409
|
}] };
|
|
2228
2410
|
}
|
|
2229
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
|
+
},
|
|
2230
2457
|
{
|
|
2231
2458
|
name: "get_ticket_attachments",
|
|
2232
2459
|
namespace: "tickets",
|
|
@@ -2610,6 +2837,88 @@ const createTicketTools = (ctx) => {
|
|
|
2610
2837
|
}] };
|
|
2611
2838
|
}
|
|
2612
2839
|
},
|
|
2840
|
+
{
|
|
2841
|
+
name: "list_views",
|
|
2842
|
+
namespace: "tickets",
|
|
2843
|
+
readOnly: true,
|
|
2844
|
+
title: "List Zendesk Views",
|
|
2845
|
+
description: "List the agent's active Zendesk views — the saved ticket queues (\"Unassigned tickets\", \"My open tickets\", \"Breaching today\") the agent sees in the Zendesk UI — each with its current ticket count so you can tell at a glance where the workload sits. Views are per-agent scoped, so per-user auth returns exactly the queues this agent can see, with no shared key. Counts come from Zendesk's cache and can lag by up to about an hour (shown as \"(count updating)\" while a fresh value is still being computed); pass a view's title or id to get_view_tickets to read the tickets inside it.",
|
|
2846
|
+
inputSchema: z.object({
|
|
2847
|
+
page_size: z.number().int().min(1).max(100).default(100).describe("Views per page (1-100, default 100)."),
|
|
2848
|
+
cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page.")
|
|
2849
|
+
}),
|
|
2850
|
+
annotations: {
|
|
2851
|
+
readOnlyHint: true,
|
|
2852
|
+
destructiveHint: false,
|
|
2853
|
+
idempotentHint: true,
|
|
2854
|
+
openWorldHint: true
|
|
2855
|
+
},
|
|
2856
|
+
handler: async (params) => {
|
|
2857
|
+
const { page_size, cursor } = params;
|
|
2858
|
+
const token = await getToken();
|
|
2859
|
+
const response = await zendeskGet(subdomain, token, "/views", {
|
|
2860
|
+
active: "true",
|
|
2861
|
+
...buildCursorParams(page_size, cursor)
|
|
2862
|
+
});
|
|
2863
|
+
const views = response.views ?? [];
|
|
2864
|
+
const counts = await fetchViewCounts(subdomain, token, views.map((v) => v.id));
|
|
2865
|
+
return { content: [{
|
|
2866
|
+
type: "text",
|
|
2867
|
+
text: formatList(views, (view) => formatView(view, counts.get(view.id)), extractPaginationMeta(response, views.length))
|
|
2868
|
+
}] };
|
|
2869
|
+
}
|
|
2870
|
+
},
|
|
2871
|
+
{
|
|
2872
|
+
name: "get_view_tickets",
|
|
2873
|
+
namespace: "tickets",
|
|
2874
|
+
readOnly: true,
|
|
2875
|
+
title: "Get Tickets In A View",
|
|
2876
|
+
description: "Read the tickets inside a Zendesk view, in the view's own configured sort order — the same order the agent sees in the Zendesk UI — which is the natural way to work a named queue like \"Unassigned tickets\" or \"Breaching today\". Accepts the view by title or by numeric id (discover both with list_views); a title is matched case-insensitively against the agent's active views, and on no match the available titles are returned so you can retry in one step. Tickets come back with the same fields as list_tickets and are cursor-paginated; there is no live SLA block here (use search_tickets when you need per-ticket SLA state), and sort_by/sort_order override the view's order when you want a different cut.",
|
|
2877
|
+
inputSchema: z.object({
|
|
2878
|
+
view: z.union([z.string().min(1), z.number().int().positive()]).describe("The view to read: its exact title as shown in Zendesk (e.g. \"Unassigned tickets\") or its numeric id from list_views. A title is matched case-insensitively against your active views; on no match the tool returns the available titles instead of erroring, so you can retry with a correct one."),
|
|
2879
|
+
sort_by: z.string().optional().describe("Optional column to sort by, overriding the view's own sort. Must be one of the view's columns (e.g. \"status\", \"priority\", \"updated_at\", or a custom field id); \"subject\" and \"submitter\" are not sortable. Omit to keep the view's configured order."),
|
|
2880
|
+
sort_order: z.enum(["asc", "desc"]).optional().describe("Sort direction applied to sort_by: \"asc\" (oldest/lowest first) or \"desc\" (newest/highest first). Only meaningful together with sort_by; omit to keep the view's configured direction."),
|
|
2881
|
+
page_size: z.number().int().min(1).max(100).default(100).describe("Tickets per page (1-100, default 100)."),
|
|
2882
|
+
cursor: z.string().optional().describe("Pagination cursor from a previous response; omit for the first page.")
|
|
2883
|
+
}),
|
|
2884
|
+
annotations: {
|
|
2885
|
+
readOnlyHint: true,
|
|
2886
|
+
destructiveHint: false,
|
|
2887
|
+
idempotentHint: true,
|
|
2888
|
+
openWorldHint: true
|
|
2889
|
+
},
|
|
2890
|
+
handler: async (params) => {
|
|
2891
|
+
const { view, sort_by, sort_order, page_size, cursor } = params;
|
|
2892
|
+
const token = await getToken();
|
|
2893
|
+
const resolved = await resolveViewId(subdomain, token, view);
|
|
2894
|
+
if ("available" in resolved) return { content: [{
|
|
2895
|
+
type: "text",
|
|
2896
|
+
text: resolved.available.length > 0 ? `No active view matches "${view}". Available views: ${resolved.available.join(", ")}.` : `No active view matches "${view}", and no active views were found for this agent.`
|
|
2897
|
+
}] };
|
|
2898
|
+
let rows;
|
|
2899
|
+
let meta;
|
|
2900
|
+
try {
|
|
2901
|
+
({rows, meta} = await executeView(subdomain, token, resolved.id, {
|
|
2902
|
+
sort_by,
|
|
2903
|
+
sort_order,
|
|
2904
|
+
page_size,
|
|
2905
|
+
cursor
|
|
2906
|
+
}));
|
|
2907
|
+
} catch (error) {
|
|
2908
|
+
if (error instanceof ZendeskApiError && error.status === 403) throw new Error(`Access denied to view ${resolved.id} (HTTP 403). Zendesk views can be restricted to specific groups, and this agent is not allowed to read this one. Call list_views to see the queues available to this agent.`, { cause: error });
|
|
2909
|
+
throw error;
|
|
2910
|
+
}
|
|
2911
|
+
const ids = rows.map(extractRowTicketId).filter((id) => typeof id === "number");
|
|
2912
|
+
const tickets = await hydrateViewTickets(subdomain, token, ids);
|
|
2913
|
+
return { content: [{
|
|
2914
|
+
type: "text",
|
|
2915
|
+
text: formatList(tickets, formatTicket, {
|
|
2916
|
+
...meta,
|
|
2917
|
+
count: tickets.length
|
|
2918
|
+
})
|
|
2919
|
+
}] };
|
|
2920
|
+
}
|
|
2921
|
+
},
|
|
2613
2922
|
{
|
|
2614
2923
|
name: "list_macros",
|
|
2615
2924
|
namespace: "tickets",
|
|
@@ -2907,7 +3216,7 @@ const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnaut
|
|
|
2907
3216
|
const annotations = aggregateAnnotations(tools);
|
|
2908
3217
|
const prefix = readOnlyMode ? "[RO] " : "";
|
|
2909
3218
|
const dispatch = buildProxyDispatch(tools, onUnauthorized);
|
|
2910
|
-
server.registerTool(toolName, {
|
|
3219
|
+
return server.registerTool(toolName, {
|
|
2911
3220
|
title,
|
|
2912
3221
|
description: `${prefix}${title}. Specify the operation and its parameters.\n\nAvailable operations:\n${operationList}`,
|
|
2913
3222
|
inputSchema: {
|
|
@@ -2917,7 +3226,14 @@ const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnaut
|
|
|
2917
3226
|
annotations
|
|
2918
3227
|
}, async (args) => dispatch(args));
|
|
2919
3228
|
};
|
|
2920
|
-
|
|
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) => {
|
|
2921
3237
|
const pkg = readPackageInfo();
|
|
2922
3238
|
const instructions = buildInstructions(config);
|
|
2923
3239
|
const server = new McpServer({
|
|
@@ -2928,54 +3244,231 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
|
|
|
2928
3244
|
...instructions ? { instructions } : {}
|
|
2929
3245
|
});
|
|
2930
3246
|
logger.attachServer(server);
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
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, {
|
|
2935
3265
|
readOnly: config.readOnly,
|
|
2936
3266
|
namespaces: config.namespaces,
|
|
2937
3267
|
tools: config.tools
|
|
2938
3268
|
});
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
const
|
|
2952
|
-
|
|
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;
|
|
2953
3286
|
}
|
|
2954
|
-
|
|
3287
|
+
case "single":
|
|
3288
|
+
registered.push(registerProxyTool(server, "zendesk", "Zendesk", filteredTools, config.readOnly, onUnauthorized));
|
|
3289
|
+
break;
|
|
2955
3290
|
}
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
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;
|
|
2971
3306
|
}
|
|
2972
3307
|
logger.info("tools_registered", {
|
|
2973
3308
|
count: filteredTools.length,
|
|
2974
3309
|
mode: config.mode
|
|
2975
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);
|
|
2976
3328
|
return server;
|
|
2977
3329
|
};
|
|
2978
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
|
|
2979
3472
|
//#region src/transports/http.ts
|
|
2980
3473
|
const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
|
|
2981
3474
|
"0.0.0.0",
|
|
@@ -3321,29 +3814,25 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
3321
3814
|
};
|
|
3322
3815
|
};
|
|
3323
3816
|
//#endregion
|
|
3324
|
-
//#region src/transports/stdio.ts
|
|
3325
|
-
const startStdioTransport = async (server, logger = silentLogger) => {
|
|
3326
|
-
const transport = new StdioServerTransport();
|
|
3327
|
-
await server.connect(transport);
|
|
3328
|
-
logger.info("stdio_transport_ready");
|
|
3329
|
-
};
|
|
3330
|
-
//#endregion
|
|
3331
3817
|
//#region src/index.ts
|
|
3332
|
-
const
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
}, logger);
|
|
3338
|
-
return createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
|
|
3339
|
-
};
|
|
3818
|
+
const buildStdioTokenStore = (config, logger) => createTokenStore({
|
|
3819
|
+
subdomain: config.subdomain,
|
|
3820
|
+
oauthClientId: config.oauthClientId,
|
|
3821
|
+
callbackPort: config.callbackPort
|
|
3822
|
+
}, logger);
|
|
3340
3823
|
const main = async () => {
|
|
3341
3824
|
const config = loadConfig();
|
|
3342
3825
|
const logger = createLogger(config.logLevel);
|
|
3343
3826
|
if (config.transport === "stdio") {
|
|
3344
|
-
|
|
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);
|
|
3345
3833
|
return;
|
|
3346
3834
|
}
|
|
3835
|
+
if (config.dev) logger.warn("dev_mode_ignored_http");
|
|
3347
3836
|
await startHttpTransport(config, logger);
|
|
3348
3837
|
};
|
|
3349
3838
|
main().catch((error) => {
|