@rui.branco/jira-mcp 1.6.7 → 1.6.9

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.
Files changed (3) hide show
  1. package/index.js +594 -66
  2. package/package.json +1 -1
  3. package/setup.js +186 -17
package/index.js CHANGED
@@ -19,33 +19,92 @@ const path = require("path");
19
19
  const fetch = require("node-fetch");
20
20
  const { spawn, execSync } = require("child_process");
21
21
 
22
- // Auto-update: block until install completes so the new code runs immediately
23
- const PKG_NAME = "@rui.branco/jira-mcp";
24
- const PKG_VERSION = require("./package.json").version;
22
+ // Auto-update: check GitHub for new commits, install in background
23
+ const GITHUB_REPO = "rui-branco/jira-mcp";
24
+ const INSTALLED_SHA_FILE = path.join(__dirname, ".installed-sha");
25
25
  try {
26
- const latest = execSync(`npm view ${PKG_NAME} version`, {
27
- stdio: "pipe",
28
- timeout: 5000,
29
- })
26
+ const localSha = fs.existsSync(INSTALLED_SHA_FILE)
27
+ ? fs.readFileSync(INSTALLED_SHA_FILE, "utf-8").trim()
28
+ : "";
29
+ const remoteSha = execSync(
30
+ `git ls-remote https://github.com/${GITHUB_REPO}.git HEAD`,
31
+ { stdio: "pipe", timeout: 5000 },
32
+ )
30
33
  .toString()
34
+ .split("\t")[0]
31
35
  .trim();
32
- if (latest && latest !== PKG_VERSION) {
33
- execSync(`npm install -g ${PKG_NAME}@${latest}`, {
34
- stdio: "ignore",
35
- timeout: 30000,
36
- });
36
+ if (remoteSha && remoteSha !== localSha) {
37
+ const child = spawn(
38
+ "sh",
39
+ [
40
+ "-c",
41
+ `npm install -g git+ssh://git@github.com/${GITHUB_REPO}.git && echo "${remoteSha}" > "${INSTALLED_SHA_FILE}"`,
42
+ ],
43
+ { stdio: "ignore", detached: true },
44
+ );
45
+ child.unref();
37
46
  }
38
47
  } catch {}
39
48
 
40
- // Load Jira config
49
+ // Load Jira config (supports single-instance and multi-instance formats)
41
50
  const jiraConfigPath = path.join(
42
51
  process.env.HOME,
43
52
  ".config/jira-mcp/config.json",
44
53
  );
45
- const jiraConfig = JSON.parse(fs.readFileSync(jiraConfigPath, "utf8"));
46
- const auth = Buffer.from(`${jiraConfig.email}:${jiraConfig.token}`).toString(
47
- "base64",
48
- );
54
+ const rawConfig = JSON.parse(fs.readFileSync(jiraConfigPath, "utf8"));
55
+
56
+ // Normalize config to multi-instance format
57
+ let instances;
58
+ if (rawConfig.instances) {
59
+ // New multi-instance format
60
+ instances = rawConfig.instances.map((inst) => ({
61
+ ...inst,
62
+ projects: (inst.projects || []).map((p) => p.toUpperCase()),
63
+ auth: Buffer.from(`${inst.email}:${inst.token}`).toString("base64"),
64
+ }));
65
+ } else {
66
+ // Old single-instance format — wrap as array
67
+ instances = [
68
+ {
69
+ name: "default",
70
+ email: rawConfig.email,
71
+ token: rawConfig.token,
72
+ baseUrl: rawConfig.baseUrl,
73
+ projects: [],
74
+ auth: Buffer.from(`${rawConfig.email}:${rawConfig.token}`).toString("base64"),
75
+ },
76
+ ];
77
+ }
78
+
79
+ // Resolve default instance
80
+ const defaultInstance =
81
+ (rawConfig.defaultInstance && instances.find((i) => i.name === rawConfig.defaultInstance)) ||
82
+ instances[0];
83
+
84
+ // Re-read config file (for persisting changes)
85
+ function loadConfigFile() {
86
+ try {
87
+ return JSON.parse(fs.readFileSync(jiraConfigPath, "utf8"));
88
+ } catch {
89
+ return {};
90
+ }
91
+ }
92
+
93
+ // Instance resolution helpers
94
+ function getInstanceForProject(projectPrefix) {
95
+ const upper = projectPrefix.toUpperCase();
96
+ return instances.find((i) => i.projects.includes(upper)) || defaultInstance;
97
+ }
98
+
99
+ function getInstanceForKey(issueKey) {
100
+ const prefix = issueKey.split("-")[0];
101
+ return getInstanceForProject(prefix);
102
+ }
103
+
104
+ function getInstanceByName(name) {
105
+ if (!name) return defaultInstance;
106
+ return instances.find((i) => i.name === name) || defaultInstance;
107
+ }
49
108
 
50
109
  // Load Figma config (optional)
51
110
  let figmaConfig = null;
@@ -77,16 +136,16 @@ if (!fs.existsSync(attachmentDir)) {
77
136
 
78
137
  // ============ JIRA FUNCTIONS ============
79
138
 
80
- async function fetchJira(endpoint, options = {}) {
139
+ async function fetchJira(endpoint, options = {}, instance = defaultInstance) {
81
140
  const { method = "GET", body } = options;
82
141
  const headers = {
83
- Authorization: `Basic ${auth}`,
142
+ Authorization: `Basic ${instance.auth}`,
84
143
  Accept: "application/json",
85
144
  };
86
145
  if (body) {
87
146
  headers["Content-Type"] = "application/json";
88
147
  }
89
- const response = await fetch(`${jiraConfig.baseUrl}/rest/api/3${endpoint}`, {
148
+ const response = await fetch(`${instance.baseUrl}/rest/api/3${endpoint}`, {
90
149
  method,
91
150
  headers,
92
151
  body: body ? JSON.stringify(body) : undefined,
@@ -101,7 +160,7 @@ async function fetchJira(endpoint, options = {}) {
101
160
  return text ? JSON.parse(text) : {};
102
161
  }
103
162
 
104
- async function downloadAttachment(url, filename, issueKey) {
163
+ async function downloadAttachment(url, filename, issueKey, instance) {
105
164
  const issueDir = path.join(attachmentDir, issueKey);
106
165
  if (!fs.existsSync(issueDir)) {
107
166
  fs.mkdirSync(issueDir, { recursive: true });
@@ -113,8 +172,9 @@ async function downloadAttachment(url, filename, issueKey) {
113
172
  return localPath;
114
173
  }
115
174
 
175
+ const inst = instance || getInstanceForKey(issueKey);
116
176
  const response = await fetch(url, {
117
- headers: { Authorization: `Basic ${auth}` },
177
+ headers: { Authorization: `Basic ${inst.auth}` },
118
178
  });
119
179
 
120
180
  if (!response.ok) {
@@ -186,9 +246,9 @@ function extractTextSimple(content) {
186
246
  // Cache for user lookups to avoid repeated API calls
187
247
  const userCache = new Map();
188
248
 
189
- async function searchUser(query) {
190
- // Check cache first
191
- const cacheKey = query.toLowerCase();
249
+ async function searchUser(query, instance = defaultInstance) {
250
+ // Check cache first (instance-aware)
251
+ const cacheKey = `${instance.name}:${query.toLowerCase()}`;
192
252
  if (userCache.has(cacheKey)) {
193
253
  return userCache.get(cacheKey);
194
254
  }
@@ -197,6 +257,8 @@ async function searchUser(query) {
197
257
  // Search for users by display name
198
258
  const users = await fetchJira(
199
259
  `/user/search?query=${encodeURIComponent(query)}&maxResults=5`,
260
+ {},
261
+ instance,
200
262
  );
201
263
  if (users && users.length > 0) {
202
264
  // Find best match - prefer exact match, then starts with, then contains
@@ -223,7 +285,7 @@ async function searchUser(query) {
223
285
 
224
286
  // Parse text with @mentions and build ADF content
225
287
  // Parse inline formatting: **bold**, *italic*, @mentions
226
- async function parseInlineFormatting(text) {
288
+ async function parseInlineFormatting(text, instance = defaultInstance) {
227
289
  const nodes = [];
228
290
  // Bold (**) must come before italic (*) in alternation, backticks for inline code
229
291
  const regex = /(`(.+?)`|\*\*(.+?)\*\*|\*(.+?)\*|@([A-Z][a-zA-Zà-ÿ]*(?:\s[A-Z][a-zA-Zà-ÿ]*)*))/g;
@@ -247,7 +309,7 @@ async function parseInlineFormatting(text) {
247
309
  nodes.push({ type: "text", text: match[4], marks: [{ type: "em" }] });
248
310
  } else if (match[5] !== undefined) {
249
311
  // @Mention
250
- const user = await searchUser(match[5].trim());
312
+ const user = await searchUser(match[5].trim(), instance);
251
313
  if (user) {
252
314
  nodes.push({
253
315
  type: "mention",
@@ -269,7 +331,9 @@ async function parseInlineFormatting(text) {
269
331
  }
270
332
 
271
333
  // Parse text with markdown formatting and @mentions, build ADF content
272
- async function buildCommentADF(text) {
334
+ async function buildCommentADF(text, instance = defaultInstance) {
335
+ // Sanitize: replace em dashes and en dashes with hyphen
336
+ text = text.replace(/[—–]/g, "-");
273
337
  // Split into blocks by double newlines (paragraphs)
274
338
  const blocks = text.split(/\n\n+/);
275
339
  const content = [];
@@ -286,7 +350,7 @@ async function buildCommentADF(text) {
286
350
  const listItems = [];
287
351
  for (const line of lines) {
288
352
  const itemText = line.trimStart().substring(2);
289
- const inlineContent = await parseInlineFormatting(itemText);
353
+ const inlineContent = await parseInlineFormatting(itemText, instance);
290
354
  listItems.push({
291
355
  type: "listItem",
292
356
  content: [{ type: "paragraph", content: inlineContent }],
@@ -298,7 +362,7 @@ async function buildCommentADF(text) {
298
362
  const paragraphContent = [];
299
363
  for (let i = 0; i < lines.length; i++) {
300
364
  if (i > 0) paragraphContent.push({ type: "hardBreak" });
301
- const inlineNodes = await parseInlineFormatting(lines[i]);
365
+ const inlineNodes = await parseInlineFormatting(lines[i], instance);
302
366
  paragraphContent.push(...inlineNodes);
303
367
  }
304
368
  content.push({ type: "paragraph", content: paragraphContent });
@@ -581,9 +645,11 @@ async function fetchFigmaDesign(url) {
581
645
 
582
646
  // ============ MAIN TICKET FUNCTION ============
583
647
 
584
- async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
585
- const issue = await fetchJira(`/issue/${issueKey}?expand=renderedFields`);
648
+ async function getTicket(issueKey, downloadImages = true, fetchFigma = true, instance = null) {
649
+ instance = instance || getInstanceForKey(issueKey);
650
+ const issue = await fetchJira(`/issue/${issueKey}?expand=renderedFields`, {}, instance);
586
651
  const fields = issue.fields;
652
+ const storyPoints = fields.customfield_10016 ?? fields.story_points ?? null;
587
653
 
588
654
  let output = `# ${issueKey}: ${fields.summary}\n\n`;
589
655
  output += `**Status:** ${fields.status?.name || "Unknown"}\n`;
@@ -592,6 +658,15 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
592
658
  output += `**Assignee:** ${fields.assignee?.displayName || "Unassigned"}\n`;
593
659
  output += `**Reporter:** ${fields.reporter?.displayName || "Unknown"}\n`;
594
660
 
661
+ // Date fields
662
+ if (fields.created) output += `**Created:** ${fields.created}\n`;
663
+ if (fields.updated) output += `**Updated:** ${fields.updated}\n`;
664
+ if (fields.resolutiondate) output += `**Resolved:** ${fields.resolutiondate}\n`;
665
+ if (fields.resolution) output += `**Resolution:** ${fields.resolution.name}\n`;
666
+
667
+ // Story points
668
+ if (storyPoints != null) output += `**Story Points:** ${storyPoints}\n`;
669
+
595
670
  if (fields.sprint) {
596
671
  output += `**Sprint:** ${fields.sprint.name}\n`;
597
672
  }
@@ -600,6 +675,16 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
600
675
  output += `**Parent:** ${fields.parent.key} - ${fields.parent.fields?.summary || ""}\n`;
601
676
  }
602
677
 
678
+ // Labels, Components, Fix Versions
679
+ if (fields.labels?.length > 0) output += `**Labels:** ${fields.labels.join(", ")}\n`;
680
+ if (fields.components?.length > 0) output += `**Components:** ${fields.components.map(c => c.name).join(", ")}\n`;
681
+ if (fields.fixVersions?.length > 0) output += `**Fix Versions:** ${fields.fixVersions.map(v => v.name).join(", ")}\n`;
682
+
683
+ // Time tracking
684
+ if (fields.timetracking && (fields.timetracking.originalEstimate || fields.timetracking.timeSpent)) {
685
+ output += `**Time Tracking:** estimate=${fields.timetracking.originalEstimate || "none"}, spent=${fields.timetracking.timeSpent || "none"}, remaining=${fields.timetracking.remainingEstimate || "none"}\n`;
686
+ }
687
+
603
688
  // Subtasks
604
689
  if (fields.subtasks?.length > 0) {
605
690
  output += `**Subtasks:** ${fields.subtasks.length}\n`;
@@ -620,7 +705,7 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
620
705
  output += `\n## Parent Ticket: ${fields.parent.key}\n\n`;
621
706
  try {
622
707
  const parentIssue = await fetchJira(
623
- `/issue/${fields.parent.key}?expand=renderedFields`,
708
+ `/issue/${fields.parent.key}?expand=renderedFields`, {}, instance,
624
709
  );
625
710
  const pf = parentIssue.fields;
626
711
 
@@ -685,6 +770,7 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
685
770
  att.content,
686
771
  att.filename,
687
772
  issueKey,
773
+ instance,
688
774
  );
689
775
  output += ` Local: ${localPath}\n`;
690
776
  downloadedImages.push(localPath);
@@ -707,7 +793,7 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
707
793
  output += `Type: ${subtask.fields?.issuetype?.name || "Subtask"}\n`;
708
794
 
709
795
  try {
710
- const subtaskDetails = await fetchJira(`/issue/${subtask.key}`);
796
+ const subtaskDetails = await fetchJira(`/issue/${subtask.key}`, {}, instance);
711
797
  const sf = subtaskDetails.fields;
712
798
 
713
799
  if (sf.assignee) {
@@ -761,7 +847,7 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
761
847
 
762
848
  try {
763
849
  const linkedIssue = await fetchJira(
764
- `/issue/${linked.key}?expand=renderedFields`,
850
+ `/issue/${linked.key}?expand=renderedFields`, {}, instance,
765
851
  );
766
852
  const lf = linkedIssue.fields;
767
853
 
@@ -833,7 +919,7 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
833
919
 
834
920
  try {
835
921
  const refIssue = await fetchJira(
836
- `/issue/${refKey}?expand=renderedFields`,
922
+ `/issue/${refKey}?expand=renderedFields`, {}, instance,
837
923
  );
838
924
  const rf = refIssue.fields;
839
925
 
@@ -919,17 +1005,207 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
919
1005
  return { text: output, jiraImages: downloadedImages, figmaDesigns };
920
1006
  }
921
1007
 
922
- async function searchTickets(jql, maxResults = 10) {
1008
+ async function searchTickets(jql, maxResults = 10, fields = null, instance = defaultInstance) {
1009
+ const defaultFields = [
1010
+ "summary", "status", "assignee", "reporter", "issuetype", "priority",
1011
+ "created", "resolutiondate", "updated", "statuscategorychangedate",
1012
+ "resolution", "timetracking", "aggregatetimeoriginalestimate",
1013
+ "aggregatetimespent", "parent", "labels", "components", "fixVersions",
1014
+ "customfield_10016", // story points (Jira Software)
1015
+ ];
1016
+ const requestFields = fields || defaultFields;
923
1017
  const data = await fetchJira(
924
- `/search/jql?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}`,
1018
+ `/search/jql?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}&fields=${requestFields.join(",")}`,
1019
+ {},
1020
+ instance,
925
1021
  );
926
1022
 
927
- let output = `# Search Results (${data.total} total, showing ${data.issues.length})\n\n`;
1023
+ const issues = data.issues || [];
1024
+ let output = `# Search Results (${data.total || 0} total, showing ${issues.length})\n\n`;
1025
+
1026
+ for (const issue of issues) {
1027
+ const f = issue.fields || {};
1028
+ const storyPoints = f.customfield_10016 ?? f.story_points ?? null;
1029
+
1030
+ output += `- **${issue.key}**: ${f.summary || "No summary"}\n`;
1031
+ output += ` Status: ${f.status?.name || "Unknown"} | Type: ${f.issuetype?.name || "Unknown"} | Priority: ${f.priority?.name || "None"}\n`;
1032
+ output += ` Assignee: ${f.assignee?.displayName || "Unassigned"} | Reporter: ${f.reporter?.displayName || "Unknown"}\n`;
1033
+
1034
+ if (f.created) output += ` Created: ${f.created}\n`;
1035
+ if (f.updated) output += ` Updated: ${f.updated}\n`;
1036
+ if (f.resolutiondate) output += ` Resolved: ${f.resolutiondate}\n`;
1037
+ if (f.resolution) output += ` Resolution: ${f.resolution.name}\n`;
1038
+ if (storyPoints != null) output += ` Story Points: ${storyPoints}\n`;
1039
+ if (f.parent) output += ` Parent: ${f.parent.key}${f.parent.fields?.summary ? ` - ${f.parent.fields.summary}` : ""}\n`;
1040
+ if (f.labels?.length > 0) output += ` Labels: ${f.labels.join(", ")}\n`;
1041
+ if (f.components?.length > 0) output += ` Components: ${f.components.map(c => c.name).join(", ")}\n`;
1042
+ if (f.fixVersions?.length > 0) output += ` Fix Versions: ${f.fixVersions.map(v => v.name).join(", ")}\n`;
1043
+ if (f.timetracking && (f.timetracking.originalEstimate || f.timetracking.timeSpent)) {
1044
+ output += ` Time Tracking: estimate=${f.timetracking.originalEstimate || "none"}, spent=${f.timetracking.timeSpent || "none"}, remaining=${f.timetracking.remainingEstimate || "none"}\n`;
1045
+ }
928
1046
 
929
- for (const issue of data.issues) {
930
- const f = issue.fields;
931
- output += `- **${issue.key}**: ${f.summary}\n`;
932
- output += ` Status: ${f.status?.name} | Assignee: ${f.assignee?.displayName || "Unassigned"}\n\n`;
1047
+ output += "\n";
1048
+ }
1049
+
1050
+ return output;
1051
+ }
1052
+
1053
+ // ============ CHANGELOG FUNCTIONS ============
1054
+
1055
+ function parseStatusHistory(changelog) {
1056
+ const statusChanges = [];
1057
+ if (!changelog?.histories) return statusChanges;
1058
+
1059
+ for (const history of changelog.histories) {
1060
+ for (const item of history.items || []) {
1061
+ if (item.field === "status") {
1062
+ statusChanges.push({
1063
+ from: item.fromString,
1064
+ to: item.toString,
1065
+ date: history.created,
1066
+ author: history.author?.displayName || "Unknown",
1067
+ });
1068
+ }
1069
+ }
1070
+ }
1071
+
1072
+ // Sort chronologically
1073
+ statusChanges.sort((a, b) => new Date(a.date) - new Date(b.date));
1074
+ return statusChanges;
1075
+ }
1076
+
1077
+ function computeMetrics(statusHistory, created) {
1078
+ if (!statusHistory.length) return null;
1079
+
1080
+ const metrics = {
1081
+ cycleTime: null,
1082
+ leadTime: null,
1083
+ timesInProgress: 0,
1084
+ timeInStatus: {},
1085
+ };
1086
+
1087
+ // Build timeline: start with created date in the initial status
1088
+ const timeline = [];
1089
+ if (created && statusHistory.length > 0) {
1090
+ timeline.push({ status: statusHistory[0].from, date: new Date(created) });
1091
+ }
1092
+ for (const change of statusHistory) {
1093
+ timeline.push({ status: change.to, date: new Date(change.date) });
1094
+ }
1095
+
1096
+ // Calculate time in each status
1097
+ for (let i = 0; i < timeline.length - 1; i++) {
1098
+ const status = timeline[i].status;
1099
+ const duration = timeline[i + 1].date - timeline[i].date;
1100
+ metrics.timeInStatus[status] = (metrics.timeInStatus[status] || 0) + duration;
1101
+ }
1102
+ // Add current status (time since last transition)
1103
+ const last = timeline[timeline.length - 1];
1104
+ const sinceLastTransition = Date.now() - last.date;
1105
+ metrics.timeInStatus[last.status] = (metrics.timeInStatus[last.status] || 0) + sinceLastTransition;
1106
+
1107
+ // Count times in progress-like statuses
1108
+ metrics.timesInProgress = statusHistory.filter(
1109
+ (c) => c.to.toLowerCase().includes("progress"),
1110
+ ).length;
1111
+
1112
+ // Cycle time: first "In Progress" to last "Done"
1113
+ const firstInProgress = statusHistory.find(
1114
+ (c) => c.to.toLowerCase().includes("progress"),
1115
+ );
1116
+ const lastDone = [...statusHistory].reverse().find(
1117
+ (c) => c.to.toLowerCase() === "done" || c.to.toLowerCase() === "closed",
1118
+ );
1119
+ if (firstInProgress && lastDone) {
1120
+ metrics.cycleTime = new Date(lastDone.date) - new Date(firstInProgress.date);
1121
+ }
1122
+
1123
+ // Lead time: created to done
1124
+ if (created && lastDone) {
1125
+ metrics.leadTime = new Date(lastDone.date) - new Date(created);
1126
+ }
1127
+
1128
+ // Format durations
1129
+ const fmt = (ms) => {
1130
+ if (ms == null) return null;
1131
+ const totalMinutes = Math.floor(ms / 60000);
1132
+ const days = Math.floor(totalMinutes / (60 * 24));
1133
+ const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
1134
+ const minutes = totalMinutes % 60;
1135
+ const parts = [];
1136
+ if (days > 0) parts.push(`${days}d`);
1137
+ if (hours > 0) parts.push(`${hours}h`);
1138
+ parts.push(`${minutes}m`);
1139
+ return parts.join(" ");
1140
+ };
1141
+
1142
+ return {
1143
+ cycleTime: fmt(metrics.cycleTime),
1144
+ leadTime: fmt(metrics.leadTime),
1145
+ timesInProgress: metrics.timesInProgress,
1146
+ timeInStatus: Object.fromEntries(
1147
+ Object.entries(metrics.timeInStatus).map(([k, v]) => [k, fmt(v)]),
1148
+ ),
1149
+ };
1150
+ }
1151
+
1152
+ function formatChangelog(issueKey, statusHistory, created) {
1153
+ let output = `## Status History for ${issueKey}\n\n`;
1154
+
1155
+ if (statusHistory.length === 0) {
1156
+ output += "_No status transitions found._\n";
1157
+ return output;
1158
+ }
1159
+
1160
+ for (const change of statusHistory) {
1161
+ const date = new Date(change.date).toLocaleString();
1162
+ output += `- **${change.from}** → **${change.to}** (${date}, by ${change.author})\n`;
1163
+ }
1164
+
1165
+ const computed = computeMetrics(statusHistory, created);
1166
+ if (computed) {
1167
+ output += `\n### Computed Metrics\n\n`;
1168
+ if (computed.cycleTime) output += `- **Cycle Time:** ${computed.cycleTime}\n`;
1169
+ if (computed.leadTime) output += `- **Lead Time:** ${computed.leadTime}\n`;
1170
+ output += `- **Times In Progress:** ${computed.timesInProgress}\n`;
1171
+ if (Object.keys(computed.timeInStatus).length > 0) {
1172
+ output += `- **Time in Status:**\n`;
1173
+ for (const [status, time] of Object.entries(computed.timeInStatus)) {
1174
+ output += ` - ${status}: ${time}\n`;
1175
+ }
1176
+ }
1177
+ }
1178
+
1179
+ return output;
1180
+ }
1181
+
1182
+ async function getChangelog(issueKey, instance = null) {
1183
+ instance = instance || getInstanceForKey(issueKey);
1184
+ const issue = await fetchJira(`/issue/${issueKey}?expand=changelog&fields=created`, {}, instance);
1185
+ const statusHistory = parseStatusHistory(issue.changelog);
1186
+ const created = issue.fields?.created;
1187
+ return { statusHistory, created, formatted: formatChangelog(issueKey, statusHistory, created) };
1188
+ }
1189
+
1190
+ async function getChangelogsBulk(jql, maxResults = 50, instance = defaultInstance) {
1191
+ // First get the issue keys matching the JQL
1192
+ const data = await fetchJira(
1193
+ `/search/jql?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}&fields=key,created`,
1194
+ {},
1195
+ instance,
1196
+ );
1197
+ const issues = data.issues || [];
1198
+
1199
+ let output = `# Changelogs (${issues.length} of ${data.total || 0} issues)\n\n`;
1200
+
1201
+ // Fetch changelog for each issue (JIRA search API doesn't support expand=changelog on /search/jql)
1202
+ for (const issue of issues) {
1203
+ try {
1204
+ const result = await getChangelog(issue.key, instance);
1205
+ output += result.formatted + "\n";
1206
+ } catch (e) {
1207
+ output += `## ${issue.key}\n\n_Error fetching changelog: ${e.message}_\n\n`;
1208
+ }
933
1209
  }
934
1210
 
935
1211
  return output;
@@ -948,10 +1224,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
948
1224
  {
949
1225
  name: "jira_get_myself",
950
1226
  description:
951
- "Get the current authenticated user's info including accountId. Use this to get your account ID for assigning tickets.",
1227
+ "Get the current authenticated user's info including accountId. Use this to get your account ID for assigning tickets. When multiple Jira instances are configured, use the 'instance' parameter to specify which one.",
952
1228
  inputSchema: {
953
1229
  type: "object",
954
- properties: {},
1230
+ properties: {
1231
+ instance: {
1232
+ type: "string",
1233
+ description: "Instance name (for multi-instance setups). Uses default instance if omitted.",
1234
+ },
1235
+ },
955
1236
  required: [],
956
1237
  },
957
1238
  },
@@ -982,7 +1263,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
982
1263
  {
983
1264
  name: "jira_search",
984
1265
  description:
985
- "Search Jira tickets using JQL. Examples: 'project = MODS AND status = Open'",
1266
+ "Search Jira tickets using JQL. Returns detailed fields including dates, story points, labels, components, fix versions, time tracking, and parent. Examples: 'project = MODS AND status = Open'. When multiple Jira instances are configured, use the 'instance' parameter to specify which one.",
986
1267
  inputSchema: {
987
1268
  type: "object",
988
1269
  properties: {
@@ -991,6 +1272,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
991
1272
  type: "number",
992
1273
  description: "Max results (default 10)",
993
1274
  },
1275
+ fields: {
1276
+ type: "array",
1277
+ items: { type: "string" },
1278
+ description:
1279
+ "Custom list of fields to return. Default includes: summary, status, assignee, reporter, issuetype, priority, created, resolutiondate, updated, resolution, timetracking, parent, labels, components, fixVersions, customfield_10016 (story points).",
1280
+ },
1281
+ instance: {
1282
+ type: "string",
1283
+ description: "Instance name (for multi-instance setups). Uses default instance if omitted.",
1284
+ },
994
1285
  },
995
1286
  required: ["jql"],
996
1287
  },
@@ -998,7 +1289,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
998
1289
  {
999
1290
  name: "jira_add_comment",
1000
1291
  description:
1001
- "Add a comment to a Jira ticket. IMPORTANT: Use @DisplayName (e.g. @Julia Pereszta) for mentions — NOT [~accountId:...] syntax. Keep comments non-technical and user-facing. Never mention git details like 'pushed to main', branch names, or technical implementation details — stakeholders don't care about that.",
1292
+ "Add a comment to a Jira ticket. IMPORTANT: Use @DisplayName (e.g. @Julia Pereszta) for mentions — NOT [~accountId:...] syntax. Keep comments non-technical and user-facing. Never mention git details like 'pushed to main', branch names, or technical implementation details — stakeholders don't care about that. NEVER use em dashes (—) or en dashes (–) in comments — use commas, periods, or rewrite the sentence instead.",
1002
1293
  inputSchema: {
1003
1294
  type: "object",
1004
1295
  properties: {
@@ -1101,7 +1392,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1101
1392
  {
1102
1393
  name: "jira_update_ticket",
1103
1394
  description:
1104
- "Update fields on a Jira ticket. IMPORTANT: Only pass the fields you want to change. Omitted fields are left untouched.",
1395
+ "Update fields on a Jira ticket. IMPORTANT: Only pass the fields you want to change. Omitted fields are left untouched. NEVER use em dashes (—) or en dashes (–) in text — use commas, periods, or rewrite the sentence instead.",
1105
1396
  inputSchema: {
1106
1397
  type: "object",
1107
1398
  properties: {
@@ -1154,7 +1445,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1154
1445
  {
1155
1446
  name: "jira_search_users",
1156
1447
  description:
1157
- "Search for Jira users by name or email. Returns account IDs and display names. Use this to find users for mentions or assignments.",
1448
+ "Search for Jira users by name or email. Returns account IDs and display names. Use this to find users for mentions or assignments. When multiple Jira instances are configured, use the 'instance' parameter to specify which one.",
1158
1449
  inputSchema: {
1159
1450
  type: "object",
1160
1451
  properties: {
@@ -1167,10 +1458,105 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1167
1458
  type: "number",
1168
1459
  description: "Max results (default 5)",
1169
1460
  },
1461
+ instance: {
1462
+ type: "string",
1463
+ description: "Instance name (for multi-instance setups). Uses default instance if omitted.",
1464
+ },
1170
1465
  },
1171
1466
  required: ["query"],
1172
1467
  },
1173
1468
  },
1469
+ {
1470
+ name: "jira_get_changelog",
1471
+ description:
1472
+ "Get status change history for a Jira ticket or multiple tickets via JQL. Returns all status transitions with timestamps and authors, plus computed metrics (cycle time, lead time, time in each status). Use issueKey for a single ticket, or jql for bulk retrieval. Instance is auto-detected from issueKey, or use the 'instance' parameter with jql.",
1473
+ inputSchema: {
1474
+ type: "object",
1475
+ properties: {
1476
+ issueKey: {
1477
+ type: "string",
1478
+ description:
1479
+ "Single issue key (e.g., MODS-13996). Use this OR jql, not both.",
1480
+ },
1481
+ jql: {
1482
+ type: "string",
1483
+ description:
1484
+ "JQL query to get changelogs for multiple tickets (e.g., 'project = MODS AND sprint = 123'). Use this OR issueKey, not both.",
1485
+ },
1486
+ maxResults: {
1487
+ type: "number",
1488
+ description:
1489
+ "Max results when using jql (default 50). Each ticket requires a separate API call, so keep this reasonable.",
1490
+ },
1491
+ instance: {
1492
+ type: "string",
1493
+ description: "Instance name (for multi-instance setups with jql). Auto-detected from issueKey if provided.",
1494
+ },
1495
+ },
1496
+ required: [],
1497
+ },
1498
+ },
1499
+ {
1500
+ name: "jira_add_instance",
1501
+ description:
1502
+ "Add or update a Jira instance configuration. Saves to config and makes it available immediately without restart. Use this to connect to a new Jira instance during a session.",
1503
+ inputSchema: {
1504
+ type: "object",
1505
+ properties: {
1506
+ name: {
1507
+ type: "string",
1508
+ description: "Unique name for this instance (e.g., 'work', 'personal')",
1509
+ },
1510
+ email: {
1511
+ type: "string",
1512
+ description: "Jira account email",
1513
+ },
1514
+ token: {
1515
+ type: "string",
1516
+ description: "Jira API token (from https://id.atlassian.com/manage-profile/security/api-tokens)",
1517
+ },
1518
+ baseUrl: {
1519
+ type: "string",
1520
+ description: "Jira base URL (e.g., https://company.atlassian.net)",
1521
+ },
1522
+ projects: {
1523
+ type: "array",
1524
+ items: { type: "string" },
1525
+ description: "Project key prefixes to auto-route to this instance (e.g., ['PROJ', 'ENG'])",
1526
+ },
1527
+ setDefault: {
1528
+ type: "boolean",
1529
+ description: "Set this instance as the default (default: false)",
1530
+ },
1531
+ },
1532
+ required: ["name", "email", "token", "baseUrl"],
1533
+ },
1534
+ },
1535
+ {
1536
+ name: "jira_remove_instance",
1537
+ description:
1538
+ "Remove a Jira instance configuration by name. Cannot remove the last remaining instance.",
1539
+ inputSchema: {
1540
+ type: "object",
1541
+ properties: {
1542
+ name: {
1543
+ type: "string",
1544
+ description: "Name of the instance to remove",
1545
+ },
1546
+ },
1547
+ required: ["name"],
1548
+ },
1549
+ },
1550
+ {
1551
+ name: "jira_list_instances",
1552
+ description:
1553
+ "List all configured Jira instances with their names, URLs, project prefixes, and which is the default.",
1554
+ inputSchema: {
1555
+ type: "object",
1556
+ properties: {},
1557
+ required: [],
1558
+ },
1559
+ },
1174
1560
  ],
1175
1561
  };
1176
1562
  });
@@ -1180,7 +1566,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1180
1566
 
1181
1567
  try {
1182
1568
  if (name === "jira_get_myself") {
1183
- const result = await fetchJira("/myself");
1569
+ const inst = getInstanceByName(args.instance);
1570
+ const result = await fetchJira("/myself", {}, inst);
1184
1571
  return {
1185
1572
  content: [
1186
1573
  {
@@ -1190,9 +1577,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1190
1577
  ],
1191
1578
  };
1192
1579
  } else if (name === "jira_search_users") {
1580
+ const inst = getInstanceByName(args.instance);
1193
1581
  const maxResults = args.maxResults || 5;
1194
1582
  const users = await fetchJira(
1195
1583
  `/user/search?query=${encodeURIComponent(args.query)}&maxResults=${maxResults}`,
1584
+ {},
1585
+ inst,
1196
1586
  );
1197
1587
  if (!users || users.length === 0) {
1198
1588
  return {
@@ -1263,11 +1653,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1263
1653
 
1264
1654
  return { content };
1265
1655
  } else if (name === "jira_search") {
1266
- const result = await searchTickets(args.jql, args.maxResults || 10);
1656
+ const inst = getInstanceByName(args.instance);
1657
+ const result = await searchTickets(args.jql, args.maxResults || 10, args.fields || null, inst);
1267
1658
  return { content: [{ type: "text", text: result }] };
1268
1659
  } else if (name === "jira_add_comment") {
1660
+ const inst = getInstanceForKey(args.issueKey);
1269
1661
  // Build ADF content with mention support
1270
- const adfContent = await buildCommentADF(args.comment);
1662
+ const adfContent = await buildCommentADF(args.comment, inst);
1271
1663
  const body = {
1272
1664
  body: {
1273
1665
  version: 1,
@@ -1278,7 +1670,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1278
1670
  const result = await fetchJira(`/issue/${args.issueKey}/comment`, {
1279
1671
  method: "POST",
1280
1672
  body,
1281
- });
1673
+ }, inst);
1282
1674
  const author = result.author?.displayName || "Unknown";
1283
1675
  const created = new Date(result.created).toLocaleString();
1284
1676
  return {
@@ -1290,9 +1682,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1290
1682
  ],
1291
1683
  };
1292
1684
  } else if (name === "jira_reply_comment") {
1685
+ const inst = getInstanceForKey(args.issueKey);
1293
1686
  // Fetch the original comment
1294
1687
  const original = await fetchJira(
1295
1688
  `/issue/${args.issueKey}/comment/${args.commentId}`,
1689
+ {},
1690
+ inst,
1296
1691
  );
1297
1692
  const originalAuthor = original.author?.displayName || "Unknown";
1298
1693
  const originalAccountId = original.author?.accountId;
@@ -1343,7 +1738,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1343
1738
  const result = await fetchJira(`/issue/${args.issueKey}/comment`, {
1344
1739
  method: "POST",
1345
1740
  body,
1346
- });
1741
+ }, inst);
1347
1742
  const author = result.author?.displayName || "Unknown";
1348
1743
  const created = new Date(result.created).toLocaleString();
1349
1744
  return {
@@ -1355,8 +1750,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1355
1750
  ],
1356
1751
  };
1357
1752
  } else if (name === "jira_edit_comment") {
1753
+ const inst = getInstanceForKey(args.issueKey);
1358
1754
  // Build ADF content with mention support
1359
- const adfContent = await buildCommentADF(args.comment);
1755
+ const adfContent = await buildCommentADF(args.comment, inst);
1360
1756
  const body = {
1361
1757
  body: {
1362
1758
  version: 1,
@@ -1367,6 +1763,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1367
1763
  const result = await fetchJira(
1368
1764
  `/issue/${args.issueKey}/comment/${args.commentId}`,
1369
1765
  { method: "PUT", body },
1766
+ inst,
1370
1767
  );
1371
1768
  return {
1372
1769
  content: [
@@ -1377,9 +1774,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1377
1774
  ],
1378
1775
  };
1379
1776
  } else if (name === "jira_delete_comment") {
1777
+ const inst = getInstanceForKey(args.issueKey);
1380
1778
  await fetchJira(`/issue/${args.issueKey}/comment/${args.commentId}`, {
1381
1779
  method: "DELETE",
1382
- });
1780
+ }, inst);
1383
1781
  return {
1384
1782
  content: [
1385
1783
  {
@@ -1389,9 +1787,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1389
1787
  ],
1390
1788
  };
1391
1789
  } else if (name === "jira_transition") {
1790
+ const inst = getInstanceForKey(args.issueKey);
1392
1791
  if (!args.transitionId && !args.targetStatus) {
1393
1792
  // List available transitions
1394
- const result = await fetchJira(`/issue/${args.issueKey}/transitions`);
1793
+ const result = await fetchJira(`/issue/${args.issueKey}/transitions`, {}, inst);
1395
1794
  let output = `# Available transitions for ${args.issueKey}\n\n`;
1396
1795
  for (const t of result.transitions || []) {
1397
1796
  output += `- **${t.name}** (id: ${t.id}) → status: ${t.to?.name || "Unknown"}\n`;
@@ -1409,7 +1808,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1409
1808
 
1410
1809
  // Try to reach target status, with up to 3 intermediate transitions
1411
1810
  for (let attempt = 0; attempt < 3; attempt++) {
1412
- const result = await fetchJira(`/issue/${args.issueKey}/transitions`);
1811
+ const result = await fetchJira(`/issue/${args.issueKey}/transitions`, {}, inst);
1413
1812
  const available = result.transitions || [];
1414
1813
 
1415
1814
  // Check if target status is directly available
@@ -1423,7 +1822,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1423
1822
  await fetchJira(`/issue/${args.issueKey}/transitions`, {
1424
1823
  method: "POST",
1425
1824
  body: { transition: { id: directMatch.id } },
1426
- });
1825
+ }, inst);
1427
1826
  transitions.push(directMatch.to?.name || directMatch.name);
1428
1827
  return {
1429
1828
  content: [
@@ -1446,7 +1845,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1446
1845
  await fetchJira(`/issue/${args.issueKey}/transitions`, {
1447
1846
  method: "POST",
1448
1847
  body: { transition: { id: inProgress.id } },
1449
- });
1848
+ }, inst);
1450
1849
  transitions.push(inProgress.to?.name || "In Progress");
1451
1850
  continue; // Try again to find target
1452
1851
  }
@@ -1456,7 +1855,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1456
1855
  }
1457
1856
 
1458
1857
  // Could not reach target status
1459
- const result = await fetchJira(`/issue/${args.issueKey}/transitions`);
1858
+ const result = await fetchJira(`/issue/${args.issueKey}/transitions`, {}, inst);
1460
1859
  const availableNames = (result.transitions || [])
1461
1860
  .map((t) => t.to?.name || t.name)
1462
1861
  .join(", ");
@@ -1474,7 +1873,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1474
1873
  await fetchJira(`/issue/${args.issueKey}/transitions`, {
1475
1874
  method: "POST",
1476
1875
  body: { transition: { id: args.transitionId } },
1477
- });
1876
+ }, inst);
1478
1877
  return {
1479
1878
  content: [
1480
1879
  {
@@ -1484,6 +1883,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1484
1883
  ],
1485
1884
  };
1486
1885
  } else if (name === "jira_update_ticket") {
1886
+ const inst = getInstanceForKey(args.issueKey);
1487
1887
  const fields = {};
1488
1888
  if (args.summary) {
1489
1889
  if (args.replaceSummary) {
@@ -1491,7 +1891,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1491
1891
  } else {
1492
1892
  // Append to existing title (default)
1493
1893
  const issue = await fetchJira(
1494
- `/issue/${args.issueKey}?fields=summary`,
1894
+ `/issue/${args.issueKey}?fields=summary`, {}, inst,
1495
1895
  );
1496
1896
  const existing = issue.fields?.summary || "";
1497
1897
  fields.summary = existing + " " + args.summary;
@@ -1512,7 +1912,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1512
1912
  } else {
1513
1913
  // Append to existing (default)
1514
1914
  const issue = await fetchJira(
1515
- `/issue/${args.issueKey}?fields=description`,
1915
+ `/issue/${args.issueKey}?fields=description`, {}, inst,
1516
1916
  );
1517
1917
  const existing = issue.fields?.description;
1518
1918
  if (existing && existing.content) {
@@ -1529,7 +1929,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1529
1929
  }
1530
1930
  if (args.removeFromDescription) {
1531
1931
  const issue = await fetchJira(
1532
- `/issue/${args.issueKey}?fields=description`,
1932
+ `/issue/${args.issueKey}?fields=description`, {}, inst,
1533
1933
  );
1534
1934
  const existing = issue.fields?.description;
1535
1935
  if (existing && existing.content) {
@@ -1582,13 +1982,141 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1582
1982
  await fetchJira(`/issue/${args.issueKey}`, {
1583
1983
  method: "PUT",
1584
1984
  body: { fields },
1585
- });
1985
+ }, inst);
1586
1986
  const updated = Object.keys(fields).join(", ");
1587
1987
  return {
1588
1988
  content: [
1589
1989
  { type: "text", text: `Updated ${args.issueKey}: ${updated}.` },
1590
1990
  ],
1591
1991
  };
1992
+ } else if (name === "jira_get_changelog") {
1993
+ if (!args.issueKey && !args.jql) {
1994
+ return {
1995
+ content: [{ type: "text", text: "Error: Provide either issueKey or jql parameter." }],
1996
+ isError: true,
1997
+ };
1998
+ }
1999
+ if (args.issueKey) {
2000
+ const result = await getChangelog(args.issueKey);
2001
+ return { content: [{ type: "text", text: result.formatted }] };
2002
+ } else {
2003
+ const inst = getInstanceByName(args.instance);
2004
+ const result = await getChangelogsBulk(args.jql, args.maxResults || 50, inst);
2005
+ return { content: [{ type: "text", text: result }] };
2006
+ }
2007
+ } else if (name === "jira_add_instance") {
2008
+ const instName = args.name.trim();
2009
+ const projects = (args.projects || []).map((p) => p.toUpperCase());
2010
+ const authStr = Buffer.from(`${args.email}:${args.token}`).toString("base64");
2011
+ const newInstance = {
2012
+ name: instName,
2013
+ email: args.email,
2014
+ token: args.token,
2015
+ baseUrl: args.baseUrl.replace(/\/$/, ""),
2016
+ projects,
2017
+ auth: authStr,
2018
+ };
2019
+
2020
+ // Update in-memory instances
2021
+ const existingIdx = instances.findIndex((i) => i.name === instName);
2022
+ if (existingIdx >= 0) {
2023
+ instances[existingIdx] = newInstance;
2024
+ } else {
2025
+ instances.push(newInstance);
2026
+ }
2027
+
2028
+ // Update default if requested or if it's the first instance
2029
+ if (args.setDefault || instances.length === 1) {
2030
+ // Can't reassign const, but defaultInstance is used via getInstanceByName/getInstanceForKey
2031
+ // which search the instances array, so this is handled by rawConfig.defaultInstance below
2032
+ }
2033
+
2034
+ // Persist to config file
2035
+ const savedConfig = loadConfigFile();
2036
+ if (!savedConfig.instances) {
2037
+ // Migrate old format
2038
+ if (savedConfig.email) {
2039
+ savedConfig.instances = [{
2040
+ name: "default",
2041
+ email: savedConfig.email,
2042
+ token: savedConfig.token,
2043
+ baseUrl: savedConfig.baseUrl,
2044
+ projects: [],
2045
+ }];
2046
+ savedConfig.defaultInstance = "default";
2047
+ delete savedConfig.email;
2048
+ delete savedConfig.token;
2049
+ delete savedConfig.baseUrl;
2050
+ } else {
2051
+ savedConfig.instances = [];
2052
+ }
2053
+ }
2054
+
2055
+ // Save without the computed auth field
2056
+ const toSave = { name: instName, email: args.email, token: args.token, baseUrl: newInstance.baseUrl, projects };
2057
+ const savedIdx = savedConfig.instances.findIndex((i) => i.name === instName);
2058
+ if (savedIdx >= 0) {
2059
+ savedConfig.instances[savedIdx] = toSave;
2060
+ } else {
2061
+ savedConfig.instances.push(toSave);
2062
+ }
2063
+ if (args.setDefault || !savedConfig.defaultInstance) {
2064
+ savedConfig.defaultInstance = instName;
2065
+ }
2066
+ fs.writeFileSync(jiraConfigPath, JSON.stringify(savedConfig, null, 2));
2067
+
2068
+ const action = existingIdx >= 0 ? "Updated" : "Added";
2069
+ let text = `${action} instance "${instName}" (${newInstance.baseUrl}).`;
2070
+ if (projects.length > 0) text += ` Projects: ${projects.join(", ")}.`;
2071
+ if (args.setDefault) text += " Set as default.";
2072
+
2073
+ return { content: [{ type: "text", text }] };
2074
+
2075
+ } else if (name === "jira_remove_instance") {
2076
+ const instName = args.name.trim();
2077
+
2078
+ if (instances.length <= 1) {
2079
+ return {
2080
+ content: [{ type: "text", text: "Cannot remove the last remaining instance." }],
2081
+ isError: true,
2082
+ };
2083
+ }
2084
+
2085
+ const idx = instances.findIndex((i) => i.name === instName);
2086
+ if (idx < 0) {
2087
+ return {
2088
+ content: [{ type: "text", text: `Instance "${instName}" not found.` }],
2089
+ isError: true,
2090
+ };
2091
+ }
2092
+
2093
+ instances.splice(idx, 1);
2094
+
2095
+ // Persist to config file
2096
+ const savedConfig = loadConfigFile();
2097
+ if (savedConfig.instances) {
2098
+ savedConfig.instances = savedConfig.instances.filter((i) => i.name !== instName);
2099
+ if (savedConfig.defaultInstance === instName) {
2100
+ savedConfig.defaultInstance = savedConfig.instances[0]?.name || null;
2101
+ }
2102
+ fs.writeFileSync(jiraConfigPath, JSON.stringify(savedConfig, null, 2));
2103
+ }
2104
+
2105
+ return { content: [{ type: "text", text: `Removed instance "${instName}".` }] };
2106
+
2107
+ } else if (name === "jira_list_instances") {
2108
+ if (instances.length === 0) {
2109
+ return { content: [{ type: "text", text: "No instances configured." }] };
2110
+ }
2111
+ const currentDefault = rawConfig.defaultInstance || instances[0].name;
2112
+ let text = `# Configured Jira Instances (${instances.length})\n\n`;
2113
+ for (const inst of instances) {
2114
+ const isDefault = inst.name === currentDefault ? " **(default)**" : "";
2115
+ const projs = inst.projects?.length > 0 ? `\n Projects: ${inst.projects.join(", ")}` : "";
2116
+ text += `- **${inst.name}**${isDefault}: ${inst.baseUrl} (${inst.email})${projs}\n`;
2117
+ }
2118
+ return { content: [{ type: "text", text }] };
2119
+
1592
2120
  } else {
1593
2121
  throw new Error(`Unknown tool: ${name}`);
1594
2122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rui.branco/jira-mcp",
3
- "version": "1.6.7",
3
+ "version": "1.6.9",
4
4
  "description": "Jira MCP server for Claude Code - fetch tickets, search with JQL, update tickets, manage comments, change status, and get Figma designs",
5
5
  "main": "index.js",
6
6
  "bin": {
package/setup.js CHANGED
@@ -12,18 +12,92 @@ let args = process.argv.slice(2);
12
12
  // Skip "setup" arg if called via index.js
13
13
  if (args[0] === "setup") args = args.slice(1);
14
14
 
15
- if (args.length >= 3) {
16
- // Non-interactive mode: node setup.js <email> <token> <baseUrl>
17
- const [email, token, baseUrl] = args;
15
+ // Load existing config if present
16
+ function loadConfig() {
17
+ try {
18
+ if (fs.existsSync(configPath)) {
19
+ return JSON.parse(fs.readFileSync(configPath, "utf8"));
20
+ }
21
+ } catch {}
22
+ return null;
23
+ }
18
24
 
25
+ function saveConfig(config) {
19
26
  if (!fs.existsSync(configDir)) {
20
27
  fs.mkdirSync(configDir, { recursive: true });
21
28
  }
29
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
30
+ }
31
+
32
+ // Non-interactive: setup add <name> <email> <token> <baseUrl> <projects>
33
+ if (args[0] === "add" && args.length >= 5) {
34
+ const [, name, email, token, baseUrl, ...projectArgs] = args;
35
+ const projects = projectArgs.length > 0
36
+ ? projectArgs.join(",").split(",").map((p) => p.trim().toUpperCase()).filter(Boolean)
37
+ : [];
38
+
39
+ const config = loadConfig() || {};
40
+
41
+ // Migrate old format if needed
42
+ if (config.email && !config.instances) {
43
+ config.instances = [{
44
+ name: "default",
45
+ email: config.email,
46
+ token: config.token,
47
+ baseUrl: config.baseUrl,
48
+ projects: [],
49
+ }];
50
+ config.defaultInstance = "default";
51
+ delete config.email;
52
+ delete config.token;
53
+ delete config.baseUrl;
54
+ }
55
+
56
+ if (!config.instances) config.instances = [];
57
+
58
+ // Replace or add instance
59
+ const existing = config.instances.findIndex((i) => i.name === name);
60
+ const instance = { name, email, token, baseUrl: baseUrl.replace(/\/$/, ""), projects };
61
+ if (existing >= 0) {
62
+ config.instances[existing] = instance;
63
+ } else {
64
+ config.instances.push(instance);
65
+ }
66
+
67
+ if (!config.defaultInstance) config.defaultInstance = name;
68
+
69
+ saveConfig(config);
70
+ console.log(`Instance "${name}" saved to ${configPath}`);
71
+ if (projects.length > 0) {
72
+ console.log(`Projects: ${projects.join(", ")}`);
73
+ }
74
+ process.exit(0);
75
+ }
76
+
77
+ // Non-interactive: setup remove <name>
78
+ if (args[0] === "remove" && args.length >= 2) {
79
+ const name = args[1];
80
+ const config = loadConfig();
22
81
 
23
- fs.writeFileSync(
24
- configPath,
25
- JSON.stringify({ email, token, baseUrl: baseUrl.replace(/\/$/, "") }, null, 2)
26
- );
82
+ if (!config || !config.instances) {
83
+ console.error("No multi-instance config found.");
84
+ process.exit(1);
85
+ }
86
+
87
+ config.instances = config.instances.filter((i) => i.name !== name);
88
+ if (config.defaultInstance === name) {
89
+ config.defaultInstance = config.instances[0]?.name || null;
90
+ }
91
+
92
+ saveConfig(config);
93
+ console.log(`Instance "${name}" removed.`);
94
+ process.exit(0);
95
+ }
96
+
97
+ // Non-interactive: setup <email> <token> <baseUrl> (legacy single-instance)
98
+ if (args.length >= 3 && args[0] !== "add" && args[0] !== "remove") {
99
+ const [email, token, baseUrl] = args;
100
+ saveConfig({ email, token, baseUrl: baseUrl.replace(/\/$/, "") });
27
101
  console.log(`Config saved to ${configPath}`);
28
102
  process.exit(0);
29
103
  }
@@ -41,7 +115,34 @@ function ask(question) {
41
115
  }
42
116
 
43
117
  async function setup() {
118
+ const existing = loadConfig();
119
+ const hasInstances = existing?.instances?.length > 0;
120
+ const isOldFormat = existing?.email && !existing?.instances;
121
+
44
122
  console.log("\n=== Jira MCP Setup ===\n");
123
+
124
+ if (hasInstances || isOldFormat) {
125
+ if (isOldFormat) {
126
+ console.log(`Existing single-instance config found (${existing.baseUrl})\n`);
127
+ } else {
128
+ console.log("Existing instances:");
129
+ for (const inst of existing.instances) {
130
+ const isDefault = inst.name === existing.defaultInstance ? " (default)" : "";
131
+ const projs = inst.projects?.length > 0 ? ` [${inst.projects.join(", ")}]` : "";
132
+ console.log(` - ${inst.name}${isDefault}: ${inst.baseUrl}${projs}`);
133
+ }
134
+ console.log();
135
+ }
136
+
137
+ const action = await ask("Add new instance, or fresh setup? (add/fresh): ");
138
+ if (action.trim().toLowerCase() === "fresh") {
139
+ // Fall through to single setup
140
+ } else {
141
+ // Add instance to multi-instance config
142
+ return await addInstance(existing);
143
+ }
144
+ }
145
+
45
146
  console.log("To get your Jira API token:");
46
147
  console.log("1. Go to https://id.atlassian.com/manage-profile/security/api-tokens");
47
148
  console.log("2. Click 'Create API token'");
@@ -51,17 +152,85 @@ async function setup() {
51
152
  const token = await ask("Jira API token: ");
52
153
  const baseUrl = await ask("Jira base URL (e.g., https://company.atlassian.net): ");
53
154
 
54
- if (!fs.existsSync(configDir)) {
55
- fs.mkdirSync(configDir, { recursive: true });
155
+ saveConfig({ email, token, baseUrl: baseUrl.replace(/\/$/, "") });
156
+ console.log(`\nConfig saved to ${configPath}`);
157
+
158
+ printFigmaStatus();
159
+ printSetupComplete();
160
+ rl.close();
161
+ }
162
+
163
+ async function addInstance(config) {
164
+ // Migrate old format if needed
165
+ if (config.email && !config.instances) {
166
+ const oldName = await ask("Name for your existing instance (e.g., work): ");
167
+ const oldProjects = await ask("Project prefixes for existing instance (comma-separated, e.g., MODS,ENG): ");
168
+ config = {
169
+ instances: [{
170
+ name: oldName.trim() || "default",
171
+ email: config.email,
172
+ token: config.token,
173
+ baseUrl: config.baseUrl,
174
+ projects: oldProjects.split(",").map((p) => p.trim().toUpperCase()).filter(Boolean),
175
+ }],
176
+ defaultInstance: oldName.trim() || "default",
177
+ };
56
178
  }
57
179
 
58
- fs.writeFileSync(
59
- configPath,
60
- JSON.stringify({ email, token, baseUrl: baseUrl.replace(/\/$/, "") }, null, 2)
61
- );
62
- console.log(`\nConfig saved to ${configPath}`);
180
+ if (!config.instances) config.instances = [];
63
181
 
64
- // Check for Figma MCP
182
+ console.log("\n--- Add New Instance ---\n");
183
+ console.log("To get your Jira API token:");
184
+ console.log("1. Go to https://id.atlassian.com/manage-profile/security/api-tokens");
185
+ console.log("2. Click 'Create API token'");
186
+ console.log("3. Copy the token\n");
187
+
188
+ const name = await ask("Instance name (e.g., personal): ");
189
+ const email = await ask("Jira email: ");
190
+ const token = await ask("Jira API token: ");
191
+ const baseUrl = await ask("Jira base URL (e.g., https://company.atlassian.net): ");
192
+ const projectsInput = await ask("Project prefixes (comma-separated, e.g., SIDE,FUN): ");
193
+ const projects = projectsInput.split(",").map((p) => p.trim().toUpperCase()).filter(Boolean);
194
+
195
+ const instance = {
196
+ name: name.trim(),
197
+ email: email.trim(),
198
+ token: token.trim(),
199
+ baseUrl: baseUrl.trim().replace(/\/$/, ""),
200
+ projects,
201
+ };
202
+
203
+ // Replace or add
204
+ const idx = config.instances.findIndex((i) => i.name === instance.name);
205
+ if (idx >= 0) {
206
+ config.instances[idx] = instance;
207
+ } else {
208
+ config.instances.push(instance);
209
+ }
210
+
211
+ if (!config.defaultInstance) config.defaultInstance = instance.name;
212
+
213
+ const setDefault = await ask(`Set "${instance.name}" as default? (y/N): `);
214
+ if (setDefault.trim().toLowerCase() === "y") {
215
+ config.defaultInstance = instance.name;
216
+ }
217
+
218
+ saveConfig(config);
219
+ console.log(`\nInstance "${instance.name}" saved to ${configPath}`);
220
+
221
+ console.log("\nAll instances:");
222
+ for (const inst of config.instances) {
223
+ const isDefault = inst.name === config.defaultInstance ? " (default)" : "";
224
+ const projs = inst.projects?.length > 0 ? ` [${inst.projects.join(", ")}]` : "";
225
+ console.log(` - ${inst.name}${isDefault}: ${inst.baseUrl}${projs}`);
226
+ }
227
+
228
+ printFigmaStatus();
229
+ printSetupComplete();
230
+ rl.close();
231
+ }
232
+
233
+ function printFigmaStatus() {
65
234
  const figmaConfigPath = path.join(process.env.HOME, ".config/figma-mcp/config.json");
66
235
  if (fs.existsSync(figmaConfigPath)) {
67
236
  console.log("\n[OK] Figma MCP detected - Figma links in tickets will be fetched automatically");
@@ -69,13 +238,13 @@ async function setup() {
69
238
  console.log("\n[INFO] Figma MCP not installed - Figma links won't be fetched");
70
239
  console.log("To enable Figma integration, install figma-mcp");
71
240
  }
241
+ }
72
242
 
243
+ function printSetupComplete() {
73
244
  console.log("\n=== Setup Complete ===");
74
245
  console.log("\nIf you haven't already, add to Claude Code with:\n");
75
246
  console.log(" claude mcp add --transport stdio jira -- npx -y @rui.branco/jira-mcp");
76
247
  console.log("\nThen restart Claude Code and run /mcp to verify.");
77
-
78
- rl.close();
79
248
  }
80
249
 
81
250
  setup().catch((e) => {