@rui.branco/jira-mcp 1.6.8 → 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 +318 -52
  2. package/package.json +1 -1
  3. package/setup.js +186 -17
package/index.js CHANGED
@@ -46,15 +46,65 @@ try {
46
46
  }
47
47
  } catch {}
48
48
 
49
- // Load Jira config
49
+ // Load Jira config (supports single-instance and multi-instance formats)
50
50
  const jiraConfigPath = path.join(
51
51
  process.env.HOME,
52
52
  ".config/jira-mcp/config.json",
53
53
  );
54
- const jiraConfig = JSON.parse(fs.readFileSync(jiraConfigPath, "utf8"));
55
- const auth = Buffer.from(`${jiraConfig.email}:${jiraConfig.token}`).toString(
56
- "base64",
57
- );
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
+ }
58
108
 
59
109
  // Load Figma config (optional)
60
110
  let figmaConfig = null;
@@ -86,16 +136,16 @@ if (!fs.existsSync(attachmentDir)) {
86
136
 
87
137
  // ============ JIRA FUNCTIONS ============
88
138
 
89
- async function fetchJira(endpoint, options = {}) {
139
+ async function fetchJira(endpoint, options = {}, instance = defaultInstance) {
90
140
  const { method = "GET", body } = options;
91
141
  const headers = {
92
- Authorization: `Basic ${auth}`,
142
+ Authorization: `Basic ${instance.auth}`,
93
143
  Accept: "application/json",
94
144
  };
95
145
  if (body) {
96
146
  headers["Content-Type"] = "application/json";
97
147
  }
98
- const response = await fetch(`${jiraConfig.baseUrl}/rest/api/3${endpoint}`, {
148
+ const response = await fetch(`${instance.baseUrl}/rest/api/3${endpoint}`, {
99
149
  method,
100
150
  headers,
101
151
  body: body ? JSON.stringify(body) : undefined,
@@ -110,7 +160,7 @@ async function fetchJira(endpoint, options = {}) {
110
160
  return text ? JSON.parse(text) : {};
111
161
  }
112
162
 
113
- async function downloadAttachment(url, filename, issueKey) {
163
+ async function downloadAttachment(url, filename, issueKey, instance) {
114
164
  const issueDir = path.join(attachmentDir, issueKey);
115
165
  if (!fs.existsSync(issueDir)) {
116
166
  fs.mkdirSync(issueDir, { recursive: true });
@@ -122,8 +172,9 @@ async function downloadAttachment(url, filename, issueKey) {
122
172
  return localPath;
123
173
  }
124
174
 
175
+ const inst = instance || getInstanceForKey(issueKey);
125
176
  const response = await fetch(url, {
126
- headers: { Authorization: `Basic ${auth}` },
177
+ headers: { Authorization: `Basic ${inst.auth}` },
127
178
  });
128
179
 
129
180
  if (!response.ok) {
@@ -195,9 +246,9 @@ function extractTextSimple(content) {
195
246
  // Cache for user lookups to avoid repeated API calls
196
247
  const userCache = new Map();
197
248
 
198
- async function searchUser(query) {
199
- // Check cache first
200
- const cacheKey = query.toLowerCase();
249
+ async function searchUser(query, instance = defaultInstance) {
250
+ // Check cache first (instance-aware)
251
+ const cacheKey = `${instance.name}:${query.toLowerCase()}`;
201
252
  if (userCache.has(cacheKey)) {
202
253
  return userCache.get(cacheKey);
203
254
  }
@@ -206,6 +257,8 @@ async function searchUser(query) {
206
257
  // Search for users by display name
207
258
  const users = await fetchJira(
208
259
  `/user/search?query=${encodeURIComponent(query)}&maxResults=5`,
260
+ {},
261
+ instance,
209
262
  );
210
263
  if (users && users.length > 0) {
211
264
  // Find best match - prefer exact match, then starts with, then contains
@@ -232,7 +285,7 @@ async function searchUser(query) {
232
285
 
233
286
  // Parse text with @mentions and build ADF content
234
287
  // Parse inline formatting: **bold**, *italic*, @mentions
235
- async function parseInlineFormatting(text) {
288
+ async function parseInlineFormatting(text, instance = defaultInstance) {
236
289
  const nodes = [];
237
290
  // Bold (**) must come before italic (*) in alternation, backticks for inline code
238
291
  const regex = /(`(.+?)`|\*\*(.+?)\*\*|\*(.+?)\*|@([A-Z][a-zA-Zà-ÿ]*(?:\s[A-Z][a-zA-Zà-ÿ]*)*))/g;
@@ -256,7 +309,7 @@ async function parseInlineFormatting(text) {
256
309
  nodes.push({ type: "text", text: match[4], marks: [{ type: "em" }] });
257
310
  } else if (match[5] !== undefined) {
258
311
  // @Mention
259
- const user = await searchUser(match[5].trim());
312
+ const user = await searchUser(match[5].trim(), instance);
260
313
  if (user) {
261
314
  nodes.push({
262
315
  type: "mention",
@@ -278,7 +331,7 @@ async function parseInlineFormatting(text) {
278
331
  }
279
332
 
280
333
  // Parse text with markdown formatting and @mentions, build ADF content
281
- async function buildCommentADF(text) {
334
+ async function buildCommentADF(text, instance = defaultInstance) {
282
335
  // Sanitize: replace em dashes and en dashes with hyphen
283
336
  text = text.replace(/[—–]/g, "-");
284
337
  // Split into blocks by double newlines (paragraphs)
@@ -297,7 +350,7 @@ async function buildCommentADF(text) {
297
350
  const listItems = [];
298
351
  for (const line of lines) {
299
352
  const itemText = line.trimStart().substring(2);
300
- const inlineContent = await parseInlineFormatting(itemText);
353
+ const inlineContent = await parseInlineFormatting(itemText, instance);
301
354
  listItems.push({
302
355
  type: "listItem",
303
356
  content: [{ type: "paragraph", content: inlineContent }],
@@ -309,7 +362,7 @@ async function buildCommentADF(text) {
309
362
  const paragraphContent = [];
310
363
  for (let i = 0; i < lines.length; i++) {
311
364
  if (i > 0) paragraphContent.push({ type: "hardBreak" });
312
- const inlineNodes = await parseInlineFormatting(lines[i]);
365
+ const inlineNodes = await parseInlineFormatting(lines[i], instance);
313
366
  paragraphContent.push(...inlineNodes);
314
367
  }
315
368
  content.push({ type: "paragraph", content: paragraphContent });
@@ -592,8 +645,9 @@ async function fetchFigmaDesign(url) {
592
645
 
593
646
  // ============ MAIN TICKET FUNCTION ============
594
647
 
595
- async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
596
- 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);
597
651
  const fields = issue.fields;
598
652
  const storyPoints = fields.customfield_10016 ?? fields.story_points ?? null;
599
653
 
@@ -651,7 +705,7 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
651
705
  output += `\n## Parent Ticket: ${fields.parent.key}\n\n`;
652
706
  try {
653
707
  const parentIssue = await fetchJira(
654
- `/issue/${fields.parent.key}?expand=renderedFields`,
708
+ `/issue/${fields.parent.key}?expand=renderedFields`, {}, instance,
655
709
  );
656
710
  const pf = parentIssue.fields;
657
711
 
@@ -716,6 +770,7 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
716
770
  att.content,
717
771
  att.filename,
718
772
  issueKey,
773
+ instance,
719
774
  );
720
775
  output += ` Local: ${localPath}\n`;
721
776
  downloadedImages.push(localPath);
@@ -738,7 +793,7 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
738
793
  output += `Type: ${subtask.fields?.issuetype?.name || "Subtask"}\n`;
739
794
 
740
795
  try {
741
- const subtaskDetails = await fetchJira(`/issue/${subtask.key}`);
796
+ const subtaskDetails = await fetchJira(`/issue/${subtask.key}`, {}, instance);
742
797
  const sf = subtaskDetails.fields;
743
798
 
744
799
  if (sf.assignee) {
@@ -792,7 +847,7 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
792
847
 
793
848
  try {
794
849
  const linkedIssue = await fetchJira(
795
- `/issue/${linked.key}?expand=renderedFields`,
850
+ `/issue/${linked.key}?expand=renderedFields`, {}, instance,
796
851
  );
797
852
  const lf = linkedIssue.fields;
798
853
 
@@ -864,7 +919,7 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
864
919
 
865
920
  try {
866
921
  const refIssue = await fetchJira(
867
- `/issue/${refKey}?expand=renderedFields`,
922
+ `/issue/${refKey}?expand=renderedFields`, {}, instance,
868
923
  );
869
924
  const rf = refIssue.fields;
870
925
 
@@ -950,7 +1005,7 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
950
1005
  return { text: output, jiraImages: downloadedImages, figmaDesigns };
951
1006
  }
952
1007
 
953
- async function searchTickets(jql, maxResults = 10, fields = null) {
1008
+ async function searchTickets(jql, maxResults = 10, fields = null, instance = defaultInstance) {
954
1009
  const defaultFields = [
955
1010
  "summary", "status", "assignee", "reporter", "issuetype", "priority",
956
1011
  "created", "resolutiondate", "updated", "statuscategorychangedate",
@@ -961,6 +1016,8 @@ async function searchTickets(jql, maxResults = 10, fields = null) {
961
1016
  const requestFields = fields || defaultFields;
962
1017
  const data = await fetchJira(
963
1018
  `/search/jql?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}&fields=${requestFields.join(",")}`,
1019
+ {},
1020
+ instance,
964
1021
  );
965
1022
 
966
1023
  const issues = data.issues || [];
@@ -1122,17 +1179,20 @@ function formatChangelog(issueKey, statusHistory, created) {
1122
1179
  return output;
1123
1180
  }
1124
1181
 
1125
- async function getChangelog(issueKey) {
1126
- const issue = await fetchJira(`/issue/${issueKey}?expand=changelog&fields=created`);
1182
+ async function getChangelog(issueKey, instance = null) {
1183
+ instance = instance || getInstanceForKey(issueKey);
1184
+ const issue = await fetchJira(`/issue/${issueKey}?expand=changelog&fields=created`, {}, instance);
1127
1185
  const statusHistory = parseStatusHistory(issue.changelog);
1128
1186
  const created = issue.fields?.created;
1129
1187
  return { statusHistory, created, formatted: formatChangelog(issueKey, statusHistory, created) };
1130
1188
  }
1131
1189
 
1132
- async function getChangelogsBulk(jql, maxResults = 50) {
1190
+ async function getChangelogsBulk(jql, maxResults = 50, instance = defaultInstance) {
1133
1191
  // First get the issue keys matching the JQL
1134
1192
  const data = await fetchJira(
1135
1193
  `/search/jql?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}&fields=key,created`,
1194
+ {},
1195
+ instance,
1136
1196
  );
1137
1197
  const issues = data.issues || [];
1138
1198
 
@@ -1141,7 +1201,7 @@ async function getChangelogsBulk(jql, maxResults = 50) {
1141
1201
  // Fetch changelog for each issue (JIRA search API doesn't support expand=changelog on /search/jql)
1142
1202
  for (const issue of issues) {
1143
1203
  try {
1144
- const result = await getChangelog(issue.key);
1204
+ const result = await getChangelog(issue.key, instance);
1145
1205
  output += result.formatted + "\n";
1146
1206
  } catch (e) {
1147
1207
  output += `## ${issue.key}\n\n_Error fetching changelog: ${e.message}_\n\n`;
@@ -1164,10 +1224,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1164
1224
  {
1165
1225
  name: "jira_get_myself",
1166
1226
  description:
1167
- "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.",
1168
1228
  inputSchema: {
1169
1229
  type: "object",
1170
- properties: {},
1230
+ properties: {
1231
+ instance: {
1232
+ type: "string",
1233
+ description: "Instance name (for multi-instance setups). Uses default instance if omitted.",
1234
+ },
1235
+ },
1171
1236
  required: [],
1172
1237
  },
1173
1238
  },
@@ -1198,7 +1263,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1198
1263
  {
1199
1264
  name: "jira_search",
1200
1265
  description:
1201
- "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'",
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.",
1202
1267
  inputSchema: {
1203
1268
  type: "object",
1204
1269
  properties: {
@@ -1213,6 +1278,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1213
1278
  description:
1214
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).",
1215
1280
  },
1281
+ instance: {
1282
+ type: "string",
1283
+ description: "Instance name (for multi-instance setups). Uses default instance if omitted.",
1284
+ },
1216
1285
  },
1217
1286
  required: ["jql"],
1218
1287
  },
@@ -1376,7 +1445,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1376
1445
  {
1377
1446
  name: "jira_search_users",
1378
1447
  description:
1379
- "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.",
1380
1449
  inputSchema: {
1381
1450
  type: "object",
1382
1451
  properties: {
@@ -1389,6 +1458,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1389
1458
  type: "number",
1390
1459
  description: "Max results (default 5)",
1391
1460
  },
1461
+ instance: {
1462
+ type: "string",
1463
+ description: "Instance name (for multi-instance setups). Uses default instance if omitted.",
1464
+ },
1392
1465
  },
1393
1466
  required: ["query"],
1394
1467
  },
@@ -1396,7 +1469,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1396
1469
  {
1397
1470
  name: "jira_get_changelog",
1398
1471
  description:
1399
- "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.",
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.",
1400
1473
  inputSchema: {
1401
1474
  type: "object",
1402
1475
  properties: {
@@ -1415,10 +1488,75 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1415
1488
  description:
1416
1489
  "Max results when using jql (default 50). Each ticket requires a separate API call, so keep this reasonable.",
1417
1490
  },
1491
+ instance: {
1492
+ type: "string",
1493
+ description: "Instance name (for multi-instance setups with jql). Auto-detected from issueKey if provided.",
1494
+ },
1418
1495
  },
1419
1496
  required: [],
1420
1497
  },
1421
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
+ },
1422
1560
  ],
1423
1561
  };
1424
1562
  });
@@ -1428,7 +1566,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1428
1566
 
1429
1567
  try {
1430
1568
  if (name === "jira_get_myself") {
1431
- const result = await fetchJira("/myself");
1569
+ const inst = getInstanceByName(args.instance);
1570
+ const result = await fetchJira("/myself", {}, inst);
1432
1571
  return {
1433
1572
  content: [
1434
1573
  {
@@ -1438,9 +1577,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1438
1577
  ],
1439
1578
  };
1440
1579
  } else if (name === "jira_search_users") {
1580
+ const inst = getInstanceByName(args.instance);
1441
1581
  const maxResults = args.maxResults || 5;
1442
1582
  const users = await fetchJira(
1443
1583
  `/user/search?query=${encodeURIComponent(args.query)}&maxResults=${maxResults}`,
1584
+ {},
1585
+ inst,
1444
1586
  );
1445
1587
  if (!users || users.length === 0) {
1446
1588
  return {
@@ -1511,11 +1653,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1511
1653
 
1512
1654
  return { content };
1513
1655
  } else if (name === "jira_search") {
1514
- const result = await searchTickets(args.jql, args.maxResults || 10, args.fields || null);
1656
+ const inst = getInstanceByName(args.instance);
1657
+ const result = await searchTickets(args.jql, args.maxResults || 10, args.fields || null, inst);
1515
1658
  return { content: [{ type: "text", text: result }] };
1516
1659
  } else if (name === "jira_add_comment") {
1660
+ const inst = getInstanceForKey(args.issueKey);
1517
1661
  // Build ADF content with mention support
1518
- const adfContent = await buildCommentADF(args.comment);
1662
+ const adfContent = await buildCommentADF(args.comment, inst);
1519
1663
  const body = {
1520
1664
  body: {
1521
1665
  version: 1,
@@ -1526,7 +1670,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1526
1670
  const result = await fetchJira(`/issue/${args.issueKey}/comment`, {
1527
1671
  method: "POST",
1528
1672
  body,
1529
- });
1673
+ }, inst);
1530
1674
  const author = result.author?.displayName || "Unknown";
1531
1675
  const created = new Date(result.created).toLocaleString();
1532
1676
  return {
@@ -1538,9 +1682,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1538
1682
  ],
1539
1683
  };
1540
1684
  } else if (name === "jira_reply_comment") {
1685
+ const inst = getInstanceForKey(args.issueKey);
1541
1686
  // Fetch the original comment
1542
1687
  const original = await fetchJira(
1543
1688
  `/issue/${args.issueKey}/comment/${args.commentId}`,
1689
+ {},
1690
+ inst,
1544
1691
  );
1545
1692
  const originalAuthor = original.author?.displayName || "Unknown";
1546
1693
  const originalAccountId = original.author?.accountId;
@@ -1591,7 +1738,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1591
1738
  const result = await fetchJira(`/issue/${args.issueKey}/comment`, {
1592
1739
  method: "POST",
1593
1740
  body,
1594
- });
1741
+ }, inst);
1595
1742
  const author = result.author?.displayName || "Unknown";
1596
1743
  const created = new Date(result.created).toLocaleString();
1597
1744
  return {
@@ -1603,8 +1750,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1603
1750
  ],
1604
1751
  };
1605
1752
  } else if (name === "jira_edit_comment") {
1753
+ const inst = getInstanceForKey(args.issueKey);
1606
1754
  // Build ADF content with mention support
1607
- const adfContent = await buildCommentADF(args.comment);
1755
+ const adfContent = await buildCommentADF(args.comment, inst);
1608
1756
  const body = {
1609
1757
  body: {
1610
1758
  version: 1,
@@ -1615,6 +1763,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1615
1763
  const result = await fetchJira(
1616
1764
  `/issue/${args.issueKey}/comment/${args.commentId}`,
1617
1765
  { method: "PUT", body },
1766
+ inst,
1618
1767
  );
1619
1768
  return {
1620
1769
  content: [
@@ -1625,9 +1774,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1625
1774
  ],
1626
1775
  };
1627
1776
  } else if (name === "jira_delete_comment") {
1777
+ const inst = getInstanceForKey(args.issueKey);
1628
1778
  await fetchJira(`/issue/${args.issueKey}/comment/${args.commentId}`, {
1629
1779
  method: "DELETE",
1630
- });
1780
+ }, inst);
1631
1781
  return {
1632
1782
  content: [
1633
1783
  {
@@ -1637,9 +1787,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1637
1787
  ],
1638
1788
  };
1639
1789
  } else if (name === "jira_transition") {
1790
+ const inst = getInstanceForKey(args.issueKey);
1640
1791
  if (!args.transitionId && !args.targetStatus) {
1641
1792
  // List available transitions
1642
- const result = await fetchJira(`/issue/${args.issueKey}/transitions`);
1793
+ const result = await fetchJira(`/issue/${args.issueKey}/transitions`, {}, inst);
1643
1794
  let output = `# Available transitions for ${args.issueKey}\n\n`;
1644
1795
  for (const t of result.transitions || []) {
1645
1796
  output += `- **${t.name}** (id: ${t.id}) → status: ${t.to?.name || "Unknown"}\n`;
@@ -1657,7 +1808,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1657
1808
 
1658
1809
  // Try to reach target status, with up to 3 intermediate transitions
1659
1810
  for (let attempt = 0; attempt < 3; attempt++) {
1660
- const result = await fetchJira(`/issue/${args.issueKey}/transitions`);
1811
+ const result = await fetchJira(`/issue/${args.issueKey}/transitions`, {}, inst);
1661
1812
  const available = result.transitions || [];
1662
1813
 
1663
1814
  // Check if target status is directly available
@@ -1671,7 +1822,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1671
1822
  await fetchJira(`/issue/${args.issueKey}/transitions`, {
1672
1823
  method: "POST",
1673
1824
  body: { transition: { id: directMatch.id } },
1674
- });
1825
+ }, inst);
1675
1826
  transitions.push(directMatch.to?.name || directMatch.name);
1676
1827
  return {
1677
1828
  content: [
@@ -1694,7 +1845,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1694
1845
  await fetchJira(`/issue/${args.issueKey}/transitions`, {
1695
1846
  method: "POST",
1696
1847
  body: { transition: { id: inProgress.id } },
1697
- });
1848
+ }, inst);
1698
1849
  transitions.push(inProgress.to?.name || "In Progress");
1699
1850
  continue; // Try again to find target
1700
1851
  }
@@ -1704,7 +1855,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1704
1855
  }
1705
1856
 
1706
1857
  // Could not reach target status
1707
- const result = await fetchJira(`/issue/${args.issueKey}/transitions`);
1858
+ const result = await fetchJira(`/issue/${args.issueKey}/transitions`, {}, inst);
1708
1859
  const availableNames = (result.transitions || [])
1709
1860
  .map((t) => t.to?.name || t.name)
1710
1861
  .join(", ");
@@ -1722,7 +1873,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1722
1873
  await fetchJira(`/issue/${args.issueKey}/transitions`, {
1723
1874
  method: "POST",
1724
1875
  body: { transition: { id: args.transitionId } },
1725
- });
1876
+ }, inst);
1726
1877
  return {
1727
1878
  content: [
1728
1879
  {
@@ -1732,6 +1883,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1732
1883
  ],
1733
1884
  };
1734
1885
  } else if (name === "jira_update_ticket") {
1886
+ const inst = getInstanceForKey(args.issueKey);
1735
1887
  const fields = {};
1736
1888
  if (args.summary) {
1737
1889
  if (args.replaceSummary) {
@@ -1739,7 +1891,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1739
1891
  } else {
1740
1892
  // Append to existing title (default)
1741
1893
  const issue = await fetchJira(
1742
- `/issue/${args.issueKey}?fields=summary`,
1894
+ `/issue/${args.issueKey}?fields=summary`, {}, inst,
1743
1895
  );
1744
1896
  const existing = issue.fields?.summary || "";
1745
1897
  fields.summary = existing + " " + args.summary;
@@ -1760,7 +1912,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1760
1912
  } else {
1761
1913
  // Append to existing (default)
1762
1914
  const issue = await fetchJira(
1763
- `/issue/${args.issueKey}?fields=description`,
1915
+ `/issue/${args.issueKey}?fields=description`, {}, inst,
1764
1916
  );
1765
1917
  const existing = issue.fields?.description;
1766
1918
  if (existing && existing.content) {
@@ -1777,7 +1929,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1777
1929
  }
1778
1930
  if (args.removeFromDescription) {
1779
1931
  const issue = await fetchJira(
1780
- `/issue/${args.issueKey}?fields=description`,
1932
+ `/issue/${args.issueKey}?fields=description`, {}, inst,
1781
1933
  );
1782
1934
  const existing = issue.fields?.description;
1783
1935
  if (existing && existing.content) {
@@ -1830,7 +1982,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1830
1982
  await fetchJira(`/issue/${args.issueKey}`, {
1831
1983
  method: "PUT",
1832
1984
  body: { fields },
1833
- });
1985
+ }, inst);
1834
1986
  const updated = Object.keys(fields).join(", ");
1835
1987
  return {
1836
1988
  content: [
@@ -1848,9 +2000,123 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1848
2000
  const result = await getChangelog(args.issueKey);
1849
2001
  return { content: [{ type: "text", text: result.formatted }] };
1850
2002
  } else {
1851
- const result = await getChangelogsBulk(args.jql, args.maxResults || 50);
2003
+ const inst = getInstanceByName(args.instance);
2004
+ const result = await getChangelogsBulk(args.jql, args.maxResults || 50, inst);
1852
2005
  return { content: [{ type: "text", text: result }] };
1853
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
+
1854
2120
  } else {
1855
2121
  throw new Error(`Unknown tool: ${name}`);
1856
2122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rui.branco/jira-mcp",
3
- "version": "1.6.8",
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) => {