@fruggr/zendesk-mcp-server 2.11.0 → 2.12.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 +6 -3
- package/dist/index.js +479 -79
- 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,
|
|
@@ -858,11 +868,14 @@ const buildInstructions = (config) => {
|
|
|
858
868
|
return [
|
|
859
869
|
`This MCP server is connected to the Zendesk Help Center of "${config.subdomain}".`,
|
|
860
870
|
"",
|
|
861
|
-
`
|
|
862
|
-
"
|
|
871
|
+
`When creating or editing Help Center content, the resource ${TOPOLOGY_RESOURCE_URI} is useful context:`,
|
|
872
|
+
"it lists the active locales (and the default one), the category → section tree with IDs,",
|
|
863
873
|
"the visibility user segments, the permission groups, and your current role.",
|
|
864
|
-
"Prefer
|
|
865
|
-
"
|
|
874
|
+
"Prefer its IDs (section_id, permission_group_id, user_segment_id, locale) over guessing from names.",
|
|
875
|
+
"",
|
|
876
|
+
"It degrades gracefully: without Guide-admin / Help Center manager rights the permission-groups and",
|
|
877
|
+
"user-segments sections are marked unavailable (not empty). In that case reuse a permission_group_id",
|
|
878
|
+
"or user_segment_id from an existing article (get_article) instead."
|
|
866
879
|
].join("\n");
|
|
867
880
|
};
|
|
868
881
|
//#endregion
|
|
@@ -960,6 +973,89 @@ const formatComment = (comment) => {
|
|
|
960
973
|
lines.push("", comment.body);
|
|
961
974
|
return lines.join("\n");
|
|
962
975
|
};
|
|
976
|
+
const formatTagDiff = (before, after) => {
|
|
977
|
+
const b = new Set(Array.isArray(before) ? before.map(String) : []);
|
|
978
|
+
const a = new Set(Array.isArray(after) ? after.map(String) : []);
|
|
979
|
+
const added = [...a].filter((t) => !b.has(t)).map((t) => `+${t}`);
|
|
980
|
+
const removed = [...b].filter((t) => !a.has(t)).map((t) => `-${t}`);
|
|
981
|
+
return added.length + removed.length === 0 ? null : `- **tags**: ${[...added, ...removed].join(", ")}`;
|
|
982
|
+
};
|
|
983
|
+
const AUDIT_ENTITY_FIELDS = {
|
|
984
|
+
assignee_id: "user",
|
|
985
|
+
requester_id: "user",
|
|
986
|
+
submitter_id: "user",
|
|
987
|
+
group_id: "group"
|
|
988
|
+
};
|
|
989
|
+
const AUDIT_CREATE_FIELDS = /* @__PURE__ */ new Set([
|
|
990
|
+
"status",
|
|
991
|
+
"priority",
|
|
992
|
+
"type",
|
|
993
|
+
"assignee_id",
|
|
994
|
+
"group_id",
|
|
995
|
+
"subject",
|
|
996
|
+
"tags"
|
|
997
|
+
]);
|
|
998
|
+
const AUDIT_FIELD_LABELS = {
|
|
999
|
+
assignee_id: "assignee",
|
|
1000
|
+
requester_id: "requester",
|
|
1001
|
+
submitter_id: "submitter",
|
|
1002
|
+
group_id: "group"
|
|
1003
|
+
};
|
|
1004
|
+
const withName = (id, names) => {
|
|
1005
|
+
const n = Number(id);
|
|
1006
|
+
if (n === -1) return "System (-1)";
|
|
1007
|
+
const name = names.get(n);
|
|
1008
|
+
return name ? `${name} (${id})` : String(id);
|
|
1009
|
+
};
|
|
1010
|
+
const renderAuditValue = (field, value, names) => {
|
|
1011
|
+
if (value === null || value === void 0 || value === "") return "";
|
|
1012
|
+
const entity = AUDIT_ENTITY_FIELDS[field];
|
|
1013
|
+
if (entity === "user") return withName(value, names.users);
|
|
1014
|
+
if (entity === "group") return withName(value, names.groups);
|
|
1015
|
+
if (typeof value === "object" && !Array.isArray(value) && "minutes" in value) {
|
|
1016
|
+
const { minutes } = value;
|
|
1017
|
+
if (typeof minutes === "number") return `${minutes} min`;
|
|
1018
|
+
}
|
|
1019
|
+
return formatFieldValue(value);
|
|
1020
|
+
};
|
|
1021
|
+
const renderCreateEvent = (event, names) => {
|
|
1022
|
+
const field = event.field_name;
|
|
1023
|
+
if (!field || !AUDIT_CREATE_FIELDS.has(field)) return null;
|
|
1024
|
+
if (field === "tags") {
|
|
1025
|
+
const tags = Array.isArray(event.value) ? event.value.map(String) : [];
|
|
1026
|
+
return tags.length > 0 ? `- **tags**: ${tags.join(", ")}` : null;
|
|
1027
|
+
}
|
|
1028
|
+
const value = renderAuditValue(field, event.value, names);
|
|
1029
|
+
return value === "" ? null : `- **${AUDIT_FIELD_LABELS[field] ?? field}**: ${value}`;
|
|
1030
|
+
};
|
|
1031
|
+
const renderChangeEvent = (event, names) => {
|
|
1032
|
+
const field = event.field_name;
|
|
1033
|
+
if (!field) return null;
|
|
1034
|
+
if (field === "tags") return formatTagDiff(event.previous_value, event.value);
|
|
1035
|
+
const after = renderAuditValue(field, event.value, names);
|
|
1036
|
+
const before = renderAuditValue(field, event.previous_value, names);
|
|
1037
|
+
if (before === after) return null;
|
|
1038
|
+
return `- **${AUDIT_FIELD_LABELS[field] ?? field}**: ${before || "(none)"} → ${after || "(none)"}`;
|
|
1039
|
+
};
|
|
1040
|
+
const renderAuditEvent = (event, names) => {
|
|
1041
|
+
switch (event.type) {
|
|
1042
|
+
case "Create": return renderCreateEvent(event, names);
|
|
1043
|
+
case "Change": return renderChangeEvent(event, names);
|
|
1044
|
+
case "Comment":
|
|
1045
|
+
case "VoiceComment": return `- ${event.public === false ? "Internal note" : "Public comment"} added`;
|
|
1046
|
+
case "CommentPrivacyChange": return "- Comment visibility changed";
|
|
1047
|
+
case "FollowersChange": return "- Followers changed";
|
|
1048
|
+
case "EmailCcChange": return "- Email CCs changed";
|
|
1049
|
+
case "SatisfactionRating": return "- Satisfaction rating recorded";
|
|
1050
|
+
default: return null;
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
const formatAudit = (audit, names) => {
|
|
1054
|
+
const lines = audit.events.map((e) => renderAuditEvent(e, names)).filter((l) => l !== null);
|
|
1055
|
+
if (lines.length === 0) return null;
|
|
1056
|
+
const channel = audit.via?.channel ? ` via ${audit.via.channel}` : "";
|
|
1057
|
+
return [`### ${audit.created_at} — ${withName(audit.author_id, names.users)}${channel}`, ...lines].join("\n");
|
|
1058
|
+
};
|
|
963
1059
|
const formatUser = (user) => [
|
|
964
1060
|
`## ${user.name} (${user.id})`,
|
|
965
1061
|
`- **Email**: ${user.email}`,
|
|
@@ -978,6 +1074,7 @@ const formatArticleSummary = (article) => [
|
|
|
978
1074
|
`## ${article.title} (${article.id})`,
|
|
979
1075
|
`- **Locale**: ${article.locale} | **Source locale**: ${article.source_locale}`,
|
|
980
1076
|
`- **Section**: ${article.section_id} | **Draft**: ${article.draft}`,
|
|
1077
|
+
`- **Permission group**: ${article.permission_group_id} | **User segment**: ${article.user_segment_id ?? "everyone (no segment)"}`,
|
|
981
1078
|
typeof article.position === "number" ? `- **Position**: ${article.position}` : "",
|
|
982
1079
|
article.label_names.length > 0 ? `- **Labels**: ${article.label_names.join(", ")}` : "",
|
|
983
1080
|
`- **Created**: ${article.created_at} | **Updated**: ${article.updated_at}`
|
|
@@ -1050,6 +1147,28 @@ const extractSearchPaginationMeta = (response, perPage, page) => {
|
|
|
1050
1147
|
//#endregion
|
|
1051
1148
|
//#region src/guidance/topology.ts
|
|
1052
1149
|
/**
|
|
1150
|
+
* Resolve an admin-gated fetch to a sentinel on HTTP 403 instead of rejecting.
|
|
1151
|
+
* Enumerating permission groups and user segments requires Guide-admin / Help
|
|
1152
|
+
* Center manager rights — a tier above per-article editing — so a content-editor
|
|
1153
|
+
* token gets 403 there while the rest of the topology is readable (#161). Any
|
|
1154
|
+
* other failure rethrows; crucially a 401 still propagates so the stale token
|
|
1155
|
+
* gets invalidated (see `onUnauthorized` in `createTopologyProvider`).
|
|
1156
|
+
*/
|
|
1157
|
+
const tolerate403 = async (promise, fallback) => {
|
|
1158
|
+
try {
|
|
1159
|
+
return {
|
|
1160
|
+
value: await promise,
|
|
1161
|
+
denied: false
|
|
1162
|
+
};
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
if (error instanceof ZendeskApiError && error.status === 403) return {
|
|
1165
|
+
value: fallback,
|
|
1166
|
+
denied: true
|
|
1167
|
+
};
|
|
1168
|
+
throw error;
|
|
1169
|
+
}
|
|
1170
|
+
};
|
|
1171
|
+
/**
|
|
1053
1172
|
* Fetch the structural topology with the CALLER'S token, so the result respects
|
|
1054
1173
|
* that user's read permissions (no privileged shared credential). Categories
|
|
1055
1174
|
* and sections are each capped at one max-size page; `sectionsHasMore` /
|
|
@@ -1057,12 +1176,12 @@ const extractSearchPaginationMeta = (response, perPage, page) => {
|
|
|
1057
1176
|
*/
|
|
1058
1177
|
const fetchTopology = async (subdomain, token) => {
|
|
1059
1178
|
const pageParams = { "page[size]": String(100) };
|
|
1060
|
-
const [locales, categoriesRes, sectionsRes,
|
|
1179
|
+
const [locales, categoriesRes, sectionsRes, segments, perms, meRes] = await Promise.all([
|
|
1061
1180
|
helpCenterGet(subdomain, token, "/locales"),
|
|
1062
1181
|
helpCenterGet(subdomain, token, "/categories", pageParams),
|
|
1063
1182
|
helpCenterGet(subdomain, token, "/sections", pageParams),
|
|
1064
|
-
helpCenterGet(subdomain, token, "/user_segments"),
|
|
1065
|
-
zendeskGet(subdomain, token, "/guide/permission_groups"),
|
|
1183
|
+
tolerate403(helpCenterGet(subdomain, token, "/user_segments"), { user_segments: [] }),
|
|
1184
|
+
tolerate403(zendeskGet(subdomain, token, "/guide/permission_groups"), { permission_groups: [] }),
|
|
1066
1185
|
zendeskGet(subdomain, token, "/users/me")
|
|
1067
1186
|
]);
|
|
1068
1187
|
const categories = categoriesRes.categories ?? [];
|
|
@@ -1074,8 +1193,10 @@ const fetchTopology = async (subdomain, token) => {
|
|
|
1074
1193
|
sections,
|
|
1075
1194
|
sectionsHasMore: extractPaginationMeta(sectionsRes, sections.length).has_more,
|
|
1076
1195
|
categoriesHasMore: extractPaginationMeta(categoriesRes, categories.length).has_more,
|
|
1077
|
-
userSegments:
|
|
1078
|
-
|
|
1196
|
+
userSegments: segments.value.user_segments ?? [],
|
|
1197
|
+
userSegmentsDenied: segments.denied,
|
|
1198
|
+
permissionGroups: perms.value.permission_groups ?? [],
|
|
1199
|
+
permissionGroupsDenied: perms.denied,
|
|
1079
1200
|
currentUser: meRes.user
|
|
1080
1201
|
};
|
|
1081
1202
|
};
|
|
@@ -1106,6 +1227,15 @@ const renderTree = (data) => {
|
|
|
1106
1227
|
}
|
|
1107
1228
|
return lines.length ? lines : ["_(no categories)_"];
|
|
1108
1229
|
};
|
|
1230
|
+
/**
|
|
1231
|
+
* Render an admin-gated section as one of three states so the LLM never mistakes
|
|
1232
|
+
* "you can't see this" for "there are none": the formatted list, `_(none)_` when
|
|
1233
|
+
* genuinely empty, or `deniedNote` when the token was forbidden (403).
|
|
1234
|
+
*/
|
|
1235
|
+
const renderAdminSection = (items, denied, deniedNote) => {
|
|
1236
|
+
if (denied) return [deniedNote];
|
|
1237
|
+
return items.length ? items : ["_(none)_"];
|
|
1238
|
+
};
|
|
1109
1239
|
/** Render the topology as a compact Markdown document for the LLM context. */
|
|
1110
1240
|
const formatTopology = (data) => {
|
|
1111
1241
|
return truncateIfNeeded([
|
|
@@ -1121,10 +1251,10 @@ const formatTopology = (data) => {
|
|
|
1121
1251
|
...renderTree(data),
|
|
1122
1252
|
"",
|
|
1123
1253
|
"## Visibility (user segments)",
|
|
1124
|
-
...data.userSegments.
|
|
1254
|
+
...renderAdminSection(data.userSegments.map(formatUserSegment), data.userSegmentsDenied, "_Unavailable: listing user segments requires Guide-admin / Help Center manager rights, which this token lacks (HTTP 403). To set visibility, reuse the user_segment_id of an existing article (get_article), or omit it to default to everyone._"),
|
|
1125
1255
|
"",
|
|
1126
1256
|
"## Permission groups",
|
|
1127
|
-
...data.permissionGroups.
|
|
1257
|
+
...renderAdminSection(data.permissionGroups.map(formatPermissionGroup), data.permissionGroupsDenied, "_Unavailable: listing permission groups requires Guide-admin / Help Center manager rights, which this token lacks (HTTP 403). To create or edit an article, reuse the permission_group_id of an existing article (get_article)._")
|
|
1128
1258
|
].join("\n"));
|
|
1129
1259
|
};
|
|
1130
1260
|
/**
|
|
@@ -1557,9 +1687,16 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1557
1687
|
},
|
|
1558
1688
|
handler: async () => {
|
|
1559
1689
|
const token = await getToken();
|
|
1690
|
+
let response;
|
|
1691
|
+
try {
|
|
1692
|
+
response = await zendeskGet(subdomain, token, "/guide/permission_groups");
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
if (error instanceof ZendeskApiError && error.status === 403) throw new Error("list_permission_groups reads Guide permission groups (GET /guide/permission_groups), which Zendesk restricts to Guide admins / Help Center managers. The current token lacks that role (HTTP 403). To obtain a permission_group_id without it, read an existing article with get_article and reuse its permission_group_id.", { cause: error });
|
|
1695
|
+
throw error;
|
|
1696
|
+
}
|
|
1560
1697
|
return { content: [{
|
|
1561
1698
|
type: "text",
|
|
1562
|
-
text: formatList(
|
|
1699
|
+
text: formatList(response.permission_groups ?? [], formatPermissionGroup)
|
|
1563
1700
|
}] };
|
|
1564
1701
|
}
|
|
1565
1702
|
},
|
|
@@ -1573,8 +1710,8 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1573
1710
|
section_id: z.number().int().describe("Section that will contain the article (numeric id from list_sections)."),
|
|
1574
1711
|
title: z.string().min(1).describe("Title of the new article, in its source locale."),
|
|
1575
1712
|
body: z.string().min(1).describe("Article body as HTML (this becomes the source-locale content)."),
|
|
1576
|
-
permission_group_id: z.number().int().describe("Permission group ID (use list_permission_groups to find it)"),
|
|
1577
|
-
user_segment_id: z.number().int().optional().describe("User segment ID for visibility (use list_user_segments to find it). Defaults to everyone."),
|
|
1713
|
+
permission_group_id: z.number().int().describe("Permission group ID (use list_permission_groups to find it; if that is forbidden because the token is not a Guide admin, reuse the permission_group_id of an existing article from get_article)."),
|
|
1714
|
+
user_segment_id: z.number().int().optional().describe("User segment ID for visibility (use list_user_segments to find it; if that is forbidden because the token is not a Guide admin, reuse the user_segment_id of an existing article from get_article). Defaults to everyone."),
|
|
1578
1715
|
author_id: z.number().int().optional().describe("Author user ID. Defaults to the authenticated user."),
|
|
1579
1716
|
content_tag_ids: z.array(z.string()).optional().describe("Content tag IDs (use list_content_tags to find them)"),
|
|
1580
1717
|
locale: z.string().optional().describe("Source locale for the article, e.g. \"en-us\" or \"fr\". Defaults to the Help Center's default locale; becomes the article's source_locale."),
|
|
@@ -1610,9 +1747,9 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1610
1747
|
promoted: z.boolean().optional().describe("Set true to promote (feature) the article in its section, or false to unpromote it."),
|
|
1611
1748
|
label_names: z.array(z.string()).optional().describe("Label names for search ranking (use list_labels to see existing labels)."),
|
|
1612
1749
|
content_tag_ids: z.array(z.string()).optional().describe("Content tag ids to attach (use list_content_tags to find them)."),
|
|
1613
|
-
user_segment_id: z.number().int().optional().describe("User segment that controls who can see the article (id from list_user_segments)."),
|
|
1750
|
+
user_segment_id: z.number().int().optional().describe("User segment that controls who can see the article (id from list_user_segments; if that is forbidden because the token is not a Guide admin, reuse the user_segment_id of an existing article from get_article)."),
|
|
1614
1751
|
author_id: z.number().int().optional().describe("User id of the article author (from search_users)."),
|
|
1615
|
-
permission_group_id: z.number().int().optional().describe("Guide permission group controlling who can edit (id from list_permission_groups)."),
|
|
1752
|
+
permission_group_id: z.number().int().optional().describe("Guide permission group controlling who can edit (id from list_permission_groups; if that is forbidden because the token is not a Guide admin, reuse the permission_group_id of an existing article from get_article)."),
|
|
1616
1753
|
section_id: z.number().int().optional().describe("Move the article to this section (numeric id from list_sections)."),
|
|
1617
1754
|
position: z.number().int().min(0).optional().describe("Sort position within the section (manual ordering only; 0 = first/top). New articles default to position 0. To move an article to the END of its section, set this to one more than the highest current position: read the highest position P from list_articles with sort_by=\"position\", sort_order=\"desc\", then set position = P + 1.")
|
|
1618
1755
|
}),
|
|
@@ -1754,9 +1891,16 @@ const createHelpCenterTools = (ctx) => {
|
|
|
1754
1891
|
},
|
|
1755
1892
|
handler: async () => {
|
|
1756
1893
|
const token = await getToken();
|
|
1894
|
+
let response;
|
|
1895
|
+
try {
|
|
1896
|
+
response = await helpCenterGet(subdomain, token, "/user_segments");
|
|
1897
|
+
} catch (error) {
|
|
1898
|
+
if (error instanceof ZendeskApiError && error.status === 403) throw new Error("list_user_segments reads Help Center user segments (GET /help_center/user_segments), which Zendesk restricts to Guide admins / Help Center managers. The current token lacks that role (HTTP 403). To set an article's visibility without it, reuse the user_segment_id of an existing article (get_article), or omit user_segment_id when creating/updating to default to everyone.", { cause: error });
|
|
1899
|
+
throw error;
|
|
1900
|
+
}
|
|
1757
1901
|
return { content: [{
|
|
1758
1902
|
type: "text",
|
|
1759
|
-
text: formatList(
|
|
1903
|
+
text: formatList(response.user_segments ?? [], formatUserSegment)
|
|
1760
1904
|
}] };
|
|
1761
1905
|
}
|
|
1762
1906
|
},
|
|
@@ -2172,6 +2316,44 @@ const hydrateViewTickets = async (subdomain, token, ids) => {
|
|
|
2172
2316
|
const byId = new Map((tickets ?? []).map((t) => [t.id, t]));
|
|
2173
2317
|
return ids.map((id) => byId.get(id)).filter((t) => t !== void 0);
|
|
2174
2318
|
};
|
|
2319
|
+
const collectAuditIds = (audits) => {
|
|
2320
|
+
const userIds = /* @__PURE__ */ new Set();
|
|
2321
|
+
const groupIds = /* @__PURE__ */ new Set();
|
|
2322
|
+
const addId = (set, raw) => {
|
|
2323
|
+
const n = Number(raw);
|
|
2324
|
+
if (Number.isInteger(n) && n > 0) set.add(n);
|
|
2325
|
+
};
|
|
2326
|
+
for (const audit of audits) {
|
|
2327
|
+
addId(userIds, audit.author_id);
|
|
2328
|
+
for (const event of audit.events) {
|
|
2329
|
+
if (event.type !== "Change" && event.type !== "Create") continue;
|
|
2330
|
+
const entity = event.field_name ? AUDIT_ENTITY_FIELDS[event.field_name] : void 0;
|
|
2331
|
+
if (!entity) continue;
|
|
2332
|
+
const set = entity === "user" ? userIds : groupIds;
|
|
2333
|
+
addId(set, event.value);
|
|
2334
|
+
addId(set, event.previous_value);
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
return {
|
|
2338
|
+
userIds: [...userIds],
|
|
2339
|
+
groupIds: [...groupIds]
|
|
2340
|
+
};
|
|
2341
|
+
};
|
|
2342
|
+
const resolveAuditNames = async (subdomain, token, userIds, groupIds) => {
|
|
2343
|
+
const resolve = async (path, key, ids) => {
|
|
2344
|
+
const map = /* @__PURE__ */ new Map();
|
|
2345
|
+
for (const batch of chunk(ids, 100)) try {
|
|
2346
|
+
const res = await zendeskGet(subdomain, token, path, { ids: batch.join(",") });
|
|
2347
|
+
for (const entity of res[key] ?? []) map.set(entity.id, entity.name);
|
|
2348
|
+
} catch {}
|
|
2349
|
+
return map;
|
|
2350
|
+
};
|
|
2351
|
+
const [users, groups] = await Promise.all([resolve("/users/show_many", "users", userIds), resolve("/groups/show_many", "groups", groupIds)]);
|
|
2352
|
+
return {
|
|
2353
|
+
users,
|
|
2354
|
+
groups
|
|
2355
|
+
};
|
|
2356
|
+
};
|
|
2175
2357
|
const DIFF_SKIP_KEYS = /* @__PURE__ */ new Set([
|
|
2176
2358
|
"comment",
|
|
2177
2359
|
"fields",
|
|
@@ -2193,13 +2375,6 @@ const diffLine = (label, before, after) => {
|
|
|
2193
2375
|
const a = shownValue(after);
|
|
2194
2376
|
return b === a ? null : `- **${label}**: ${b} → ${a}`;
|
|
2195
2377
|
};
|
|
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
2378
|
const formatMacroPreviewDiff = (ticketId, macroId, before, result) => {
|
|
2204
2379
|
const after = result?.ticket ?? {};
|
|
2205
2380
|
const beforeObj = before ?? {};
|
|
@@ -2259,7 +2434,7 @@ const createTicketTools = (ctx) => {
|
|
|
2259
2434
|
namespace: "tickets",
|
|
2260
2435
|
readOnly: true,
|
|
2261
2436
|
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.",
|
|
2437
|
+
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
2438
|
inputSchema: z.object({
|
|
2264
2439
|
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
2440
|
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 +2460,51 @@ const createTicketTools = (ctx) => {
|
|
|
2285
2460
|
}] };
|
|
2286
2461
|
}
|
|
2287
2462
|
},
|
|
2463
|
+
{
|
|
2464
|
+
name: "get_ticket_history",
|
|
2465
|
+
namespace: "tickets",
|
|
2466
|
+
readOnly: true,
|
|
2467
|
+
title: "Get Zendesk Ticket History",
|
|
2468
|
+
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.",
|
|
2469
|
+
inputSchema: z.object({
|
|
2470
|
+
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."),
|
|
2471
|
+
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."),
|
|
2472
|
+
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.")
|
|
2473
|
+
}),
|
|
2474
|
+
annotations: {
|
|
2475
|
+
readOnlyHint: true,
|
|
2476
|
+
destructiveHint: false,
|
|
2477
|
+
idempotentHint: true,
|
|
2478
|
+
openWorldHint: true
|
|
2479
|
+
},
|
|
2480
|
+
handler: async (params) => {
|
|
2481
|
+
const { ticket_id, page_size, cursor } = params;
|
|
2482
|
+
const token = await getToken();
|
|
2483
|
+
let response;
|
|
2484
|
+
try {
|
|
2485
|
+
response = await zendeskGet(subdomain, token, `/tickets/${ticket_id}/audits`, buildCursorParams(page_size, cursor));
|
|
2486
|
+
} catch (error) {
|
|
2487
|
+
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 });
|
|
2488
|
+
throw error;
|
|
2489
|
+
}
|
|
2490
|
+
const audits = response.audits ?? [];
|
|
2491
|
+
const meta = extractPaginationMeta(response, audits.length);
|
|
2492
|
+
const { userIds, groupIds } = collectAuditIds(audits);
|
|
2493
|
+
const names = await resolveAuditNames(subdomain, token, userIds, groupIds);
|
|
2494
|
+
const blocks = audits.map((audit) => formatAudit(audit, names)).filter((block) => block !== null);
|
|
2495
|
+
if (blocks.length === 0) return { content: [{
|
|
2496
|
+
type: "text",
|
|
2497
|
+
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}.`
|
|
2498
|
+
}] };
|
|
2499
|
+
return { content: [{
|
|
2500
|
+
type: "text",
|
|
2501
|
+
text: `# Change history for ticket #${ticket_id}\n\n${formatList(blocks, (block) => block, {
|
|
2502
|
+
...meta,
|
|
2503
|
+
count: blocks.length
|
|
2504
|
+
})}`
|
|
2505
|
+
}] };
|
|
2506
|
+
}
|
|
2507
|
+
},
|
|
2288
2508
|
{
|
|
2289
2509
|
name: "get_ticket_attachments",
|
|
2290
2510
|
namespace: "tickets",
|
|
@@ -3047,7 +3267,7 @@ const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnaut
|
|
|
3047
3267
|
const annotations = aggregateAnnotations(tools);
|
|
3048
3268
|
const prefix = readOnlyMode ? "[RO] " : "";
|
|
3049
3269
|
const dispatch = buildProxyDispatch(tools, onUnauthorized);
|
|
3050
|
-
server.registerTool(toolName, {
|
|
3270
|
+
return server.registerTool(toolName, {
|
|
3051
3271
|
title,
|
|
3052
3272
|
description: `${prefix}${title}. Specify the operation and its parameters.\n\nAvailable operations:\n${operationList}`,
|
|
3053
3273
|
inputSchema: {
|
|
@@ -3057,7 +3277,14 @@ const registerProxyTool = (server, toolName, title, tools, readOnlyMode, onUnaut
|
|
|
3057
3277
|
annotations
|
|
3058
3278
|
}, async (args) => dispatch(args));
|
|
3059
3279
|
};
|
|
3060
|
-
|
|
3280
|
+
/**
|
|
3281
|
+
* Build the bare `McpServer` — identity, capabilities, `instructions` and the
|
|
3282
|
+
* logging sink — with no tools or resources registered yet. Split out from
|
|
3283
|
+
* `createMcpServer` so the dev reload (`dev/reload.ts`) can keep this
|
|
3284
|
+
* long-lived shell (and its transport/session) alive while swapping the toolset
|
|
3285
|
+
* underneath it via `registerToolset`.
|
|
3286
|
+
*/
|
|
3287
|
+
const createServerShell = (config, logger = silentLogger) => {
|
|
3061
3288
|
const pkg = readPackageInfo();
|
|
3062
3289
|
const instructions = buildInstructions(config);
|
|
3063
3290
|
const server = new McpServer({
|
|
@@ -3068,54 +3295,231 @@ const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized
|
|
|
3068
3295
|
...instructions ? { instructions } : {}
|
|
3069
3296
|
});
|
|
3070
3297
|
logger.attachServer(server);
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3298
|
+
return server;
|
|
3299
|
+
};
|
|
3300
|
+
/**
|
|
3301
|
+
* Registers one generation of the toolset (mode/filters applied) plus the
|
|
3302
|
+
* optional topology resource onto an existing server, and returns a handle
|
|
3303
|
+
* whose `dispose()` removes exactly what this call added. When the SDK server
|
|
3304
|
+
* is already connected, each `registerTool`/`remove` emits `list_changed`, so a
|
|
3305
|
+
* dispose-then-register cycle hot-swaps the exposed tools in place — this is the
|
|
3306
|
+
* mechanism the dev reload (`dev/reload.ts`) uses to reflect edited tool code
|
|
3307
|
+
* without dropping the transport. `tools` is passed in (not built here) so the
|
|
3308
|
+
* reload path can hand over freshly re-imported definitions.
|
|
3309
|
+
*/
|
|
3310
|
+
const registerToolset = (server, { config, getToken, onUnauthorized, logger = silentLogger }, tools) => {
|
|
3311
|
+
const registered = [];
|
|
3312
|
+
const dispose = () => {
|
|
3313
|
+
for (const handle of registered) handle.remove();
|
|
3314
|
+
};
|
|
3315
|
+
const filteredTools = filterTools(tools, {
|
|
3075
3316
|
readOnly: config.readOnly,
|
|
3076
3317
|
namespaces: config.namespaces,
|
|
3077
3318
|
tools: config.tools
|
|
3078
3319
|
});
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
const
|
|
3092
|
-
|
|
3320
|
+
try {
|
|
3321
|
+
switch (config.mode) {
|
|
3322
|
+
case "all":
|
|
3323
|
+
for (const tool of filteredTools) registered.push(server.registerTool(tool.name, {
|
|
3324
|
+
title: tool.title,
|
|
3325
|
+
description: tool.description,
|
|
3326
|
+
inputSchema: tool.inputSchema.strict(),
|
|
3327
|
+
annotations: tool.annotations
|
|
3328
|
+
}, async (params) => runHandler(tool, params, onUnauthorized)));
|
|
3329
|
+
break;
|
|
3330
|
+
case "namespace": {
|
|
3331
|
+
const grouped = groupByNamespace(filteredTools);
|
|
3332
|
+
for (const [namespace, nsTools] of grouped) {
|
|
3333
|
+
const label = NAMESPACE_LABELS[namespace];
|
|
3334
|
+
if (label) registered.push(registerProxyTool(server, label.toolName, label.title, nsTools, config.readOnly, onUnauthorized));
|
|
3335
|
+
}
|
|
3336
|
+
break;
|
|
3093
3337
|
}
|
|
3094
|
-
|
|
3338
|
+
case "single":
|
|
3339
|
+
registered.push(registerProxyTool(server, "zendesk", "Zendesk", filteredTools, config.readOnly, onUnauthorized));
|
|
3340
|
+
break;
|
|
3095
3341
|
}
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3342
|
+
if (helpCenterContextEnabled(config)) {
|
|
3343
|
+
const topology = createTopologyProvider(getToken, config.subdomain, onUnauthorized);
|
|
3344
|
+
registered.push(server.registerResource("help-center-topology", TOPOLOGY_RESOURCE_URI, {
|
|
3345
|
+
title: "Zendesk Help Center topology",
|
|
3346
|
+
description: "Active locales, category → section tree, visibility segments, permission groups, and your role. Useful context when creating or editing content; admin-only sections (permission groups, user segments) are marked unavailable rather than empty when your role lacks Guide-admin rights.",
|
|
3347
|
+
mimeType: "text/markdown"
|
|
3348
|
+
}, async (uri) => ({ contents: [{
|
|
3349
|
+
uri: uri.toString(),
|
|
3350
|
+
mimeType: "text/markdown",
|
|
3351
|
+
text: await topology.read()
|
|
3352
|
+
}] })));
|
|
3353
|
+
}
|
|
3354
|
+
} catch (err) {
|
|
3355
|
+
dispose();
|
|
3356
|
+
throw err;
|
|
3111
3357
|
}
|
|
3112
3358
|
logger.info("tools_registered", {
|
|
3113
3359
|
count: filteredTools.length,
|
|
3114
3360
|
mode: config.mode
|
|
3115
3361
|
});
|
|
3362
|
+
return {
|
|
3363
|
+
count: filteredTools.length,
|
|
3364
|
+
dispose
|
|
3365
|
+
};
|
|
3366
|
+
};
|
|
3367
|
+
const createMcpServer = (config, getToken, logger = silentLogger, onUnauthorized) => {
|
|
3368
|
+
const server = createServerShell(config, logger);
|
|
3369
|
+
const tools = createAllTools({
|
|
3370
|
+
subdomain: config.subdomain,
|
|
3371
|
+
getToken
|
|
3372
|
+
});
|
|
3373
|
+
registerToolset(server, {
|
|
3374
|
+
config,
|
|
3375
|
+
getToken,
|
|
3376
|
+
onUnauthorized,
|
|
3377
|
+
logger
|
|
3378
|
+
}, tools);
|
|
3116
3379
|
return server;
|
|
3117
3380
|
};
|
|
3118
3381
|
//#endregion
|
|
3382
|
+
//#region src/transports/stdio.ts
|
|
3383
|
+
const startStdioTransport = async (server, logger = silentLogger) => {
|
|
3384
|
+
const transport = new StdioServerTransport();
|
|
3385
|
+
await server.connect(transport);
|
|
3386
|
+
logger.info("stdio_transport_ready");
|
|
3387
|
+
};
|
|
3388
|
+
//#endregion
|
|
3389
|
+
//#region src/dev/reload.ts
|
|
3390
|
+
const toolsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "tools");
|
|
3391
|
+
const TOOL_MODULES = [
|
|
3392
|
+
{
|
|
3393
|
+
file: "tickets.ts",
|
|
3394
|
+
factory: "createTicketTools"
|
|
3395
|
+
},
|
|
3396
|
+
{
|
|
3397
|
+
file: "search.ts",
|
|
3398
|
+
factory: "createSearchTools"
|
|
3399
|
+
},
|
|
3400
|
+
{
|
|
3401
|
+
file: "help-center.ts",
|
|
3402
|
+
factory: "createHelpCenterTools"
|
|
3403
|
+
},
|
|
3404
|
+
{
|
|
3405
|
+
file: "users.ts",
|
|
3406
|
+
factory: "createUserTools"
|
|
3407
|
+
}
|
|
3408
|
+
];
|
|
3409
|
+
/**
|
|
3410
|
+
* Re-import every leaf tool-factory module with a fresh cache-busting query so
|
|
3411
|
+
* the running process sees edited tool code, then recompose the definitions
|
|
3412
|
+
* exactly as `createAllTools` does. A per-call `randomUUID()` nonce keeps the
|
|
3413
|
+
* import specifier unique (Node caches modules by specifier, so a repeated
|
|
3414
|
+
* query would serve stale code) without any shared module state.
|
|
3415
|
+
*/
|
|
3416
|
+
const loadFreshTools = async (ctx) => {
|
|
3417
|
+
const nonce = randomUUID();
|
|
3418
|
+
return (await Promise.all(TOOL_MODULES.map(async ({ file, factory }) => {
|
|
3419
|
+
const create = (await import(`${pathToFileURL(join(toolsDir, file)).href}?v=${nonce}`))[factory];
|
|
3420
|
+
/* v8 ignore next 2 -- defensive guard: only hit if a factory export is renamed */
|
|
3421
|
+
if (!create) throw new Error(`Tool module ${file} has no export ${factory}`);
|
|
3422
|
+
return create(ctx);
|
|
3423
|
+
}))).flat();
|
|
3424
|
+
};
|
|
3425
|
+
/**
|
|
3426
|
+
* A server shell plus a `reload()` that swaps in freshly re-imported tool code
|
|
3427
|
+
* over the live session and returns the new tool count. Split from
|
|
3428
|
+
* `startDevServer` so the reconciliation can be exercised without a transport:
|
|
3429
|
+
* the initial toolset is registered from the static import (fast, and reload
|
|
3430
|
+
* only matters after an edit); `reload()` disposes the current generation and
|
|
3431
|
+
* registers a fresh one.
|
|
3432
|
+
*/
|
|
3433
|
+
const createReloadableServer = (config, getToken, logger = silentLogger, onUnauthorized, loadTools = loadFreshTools) => {
|
|
3434
|
+
const server = createServerShell(config, logger);
|
|
3435
|
+
const ctx = {
|
|
3436
|
+
subdomain: config.subdomain,
|
|
3437
|
+
getToken
|
|
3438
|
+
};
|
|
3439
|
+
const params = {
|
|
3440
|
+
config,
|
|
3441
|
+
getToken,
|
|
3442
|
+
onUnauthorized,
|
|
3443
|
+
logger
|
|
3444
|
+
};
|
|
3445
|
+
let currentTools = createAllTools(ctx);
|
|
3446
|
+
let current = registerToolset(server, params, currentTools);
|
|
3447
|
+
const reload = async () => {
|
|
3448
|
+
const tools = await loadTools(ctx);
|
|
3449
|
+
current.dispose();
|
|
3450
|
+
try {
|
|
3451
|
+
current = registerToolset(server, params, tools);
|
|
3452
|
+
currentTools = tools;
|
|
3453
|
+
} catch (err) {
|
|
3454
|
+
try {
|
|
3455
|
+
current = registerToolset(server, params, currentTools);
|
|
3456
|
+
} catch (rollbackErr) {
|
|
3457
|
+
logger.error("tools_rollback_failed", { error: rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr) });
|
|
3458
|
+
}
|
|
3459
|
+
throw err;
|
|
3460
|
+
}
|
|
3461
|
+
return current.count;
|
|
3462
|
+
};
|
|
3463
|
+
return {
|
|
3464
|
+
server,
|
|
3465
|
+
reload
|
|
3466
|
+
};
|
|
3467
|
+
};
|
|
3468
|
+
/**
|
|
3469
|
+
* Register the dev-only `reload_tools` meta-tool on the server. It stays
|
|
3470
|
+
* registered across reloads (it is NOT part of the disposable toolset
|
|
3471
|
+
* generation), so it can always be called again. Its handler triggers a reload
|
|
3472
|
+
* and reports the outcome; a failed reload surfaces as a tool error while the
|
|
3473
|
+
* previous generation stays live.
|
|
3474
|
+
*/
|
|
3475
|
+
const registerReloadTool = (server, reload, logger = silentLogger) => {
|
|
3476
|
+
server.registerTool("reload_tools", {
|
|
3477
|
+
title: "Reload tools from source (dev)",
|
|
3478
|
+
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.",
|
|
3479
|
+
inputSchema: z.object({}).strict(),
|
|
3480
|
+
annotations: {
|
|
3481
|
+
readOnlyHint: false,
|
|
3482
|
+
destructiveHint: false,
|
|
3483
|
+
idempotentHint: true,
|
|
3484
|
+
openWorldHint: false
|
|
3485
|
+
}
|
|
3486
|
+
}, async () => {
|
|
3487
|
+
try {
|
|
3488
|
+
const count = await reload();
|
|
3489
|
+
logger.info("tools_reloaded", { count });
|
|
3490
|
+
return { content: [{
|
|
3491
|
+
type: "text",
|
|
3492
|
+
text: `Reloaded ${count} tool(s) from source. The updated tool list is now live (tools/list_changed sent).`
|
|
3493
|
+
}] };
|
|
3494
|
+
} catch (err) {
|
|
3495
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3496
|
+
logger.error("tools_reload_failed", { error: message });
|
|
3497
|
+
return {
|
|
3498
|
+
isError: true,
|
|
3499
|
+
content: [{
|
|
3500
|
+
type: "text",
|
|
3501
|
+
text: `Reload failed; the previously loaded tools are still live. Fix the error and call reload_tools again: ${message}`
|
|
3502
|
+
}]
|
|
3503
|
+
};
|
|
3504
|
+
}
|
|
3505
|
+
});
|
|
3506
|
+
};
|
|
3507
|
+
/**
|
|
3508
|
+
* Start the stdio server in dev mode: the normal toolset plus a persistent
|
|
3509
|
+
* `reload_tools` tool that hot-reloads edited tool code on demand. stdio only —
|
|
3510
|
+
* HTTP builds a per-session server per request, so there is no long-lived
|
|
3511
|
+
* server to hot-swap.
|
|
3512
|
+
*/
|
|
3513
|
+
/* v8 ignore start -- runtime bootstrap: binds the reload tool to a real stdio
|
|
3514
|
+
transport; the reload machinery it wires up is covered by dev-reload.test.ts */
|
|
3515
|
+
const startDevServer = async (config, getToken, logger = silentLogger, onUnauthorized) => {
|
|
3516
|
+
const { server, reload } = createReloadableServer(config, getToken, logger, onUnauthorized);
|
|
3517
|
+
registerReloadTool(server, reload, logger);
|
|
3518
|
+
await startStdioTransport(server, logger);
|
|
3519
|
+
logger.info("dev_mode_enabled");
|
|
3520
|
+
};
|
|
3521
|
+
/* v8 ignore stop */
|
|
3522
|
+
//#endregion
|
|
3119
3523
|
//#region src/transports/http.ts
|
|
3120
3524
|
const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
|
|
3121
3525
|
"0.0.0.0",
|
|
@@ -3461,29 +3865,25 @@ const startHttpTransport = async (config, logger = silentLogger, options = {}) =
|
|
|
3461
3865
|
};
|
|
3462
3866
|
};
|
|
3463
3867
|
//#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
3868
|
//#region src/index.ts
|
|
3472
|
-
const
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
}, logger);
|
|
3478
|
-
return createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
|
|
3479
|
-
};
|
|
3869
|
+
const buildStdioTokenStore = (config, logger) => createTokenStore({
|
|
3870
|
+
subdomain: config.subdomain,
|
|
3871
|
+
oauthClientId: config.oauthClientId,
|
|
3872
|
+
callbackPort: config.callbackPort
|
|
3873
|
+
}, logger);
|
|
3480
3874
|
const main = async () => {
|
|
3481
3875
|
const config = loadConfig();
|
|
3482
3876
|
const logger = createLogger(config.logLevel);
|
|
3483
3877
|
if (config.transport === "stdio") {
|
|
3484
|
-
|
|
3878
|
+
const tokenStore = buildStdioTokenStore(config, logger);
|
|
3879
|
+
if (config.dev) {
|
|
3880
|
+
await startDevServer(config, tokenStore.getToken, logger, tokenStore.invalidate);
|
|
3881
|
+
return;
|
|
3882
|
+
}
|
|
3883
|
+
await startStdioTransport(createMcpServer(config, tokenStore.getToken, logger, tokenStore.invalidate), logger);
|
|
3485
3884
|
return;
|
|
3486
3885
|
}
|
|
3886
|
+
if (config.dev) logger.warn("dev_mode_ignored_http");
|
|
3487
3887
|
await startHttpTransport(config, logger);
|
|
3488
3888
|
};
|
|
3489
3889
|
main().catch((error) => {
|