@rui.branco/jira-mcp 1.6.7 → 1.6.8

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 (2) hide show
  1. package/index.js +285 -23
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -19,21 +19,30 @@ 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
 
@@ -270,6 +279,8 @@ async function parseInlineFormatting(text) {
270
279
 
271
280
  // Parse text with markdown formatting and @mentions, build ADF content
272
281
  async function buildCommentADF(text) {
282
+ // Sanitize: replace em dashes and en dashes with hyphen
283
+ text = text.replace(/[—–]/g, "-");
273
284
  // Split into blocks by double newlines (paragraphs)
274
285
  const blocks = text.split(/\n\n+/);
275
286
  const content = [];
@@ -584,6 +595,7 @@ async function fetchFigmaDesign(url) {
584
595
  async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
585
596
  const issue = await fetchJira(`/issue/${issueKey}?expand=renderedFields`);
586
597
  const fields = issue.fields;
598
+ const storyPoints = fields.customfield_10016 ?? fields.story_points ?? null;
587
599
 
588
600
  let output = `# ${issueKey}: ${fields.summary}\n\n`;
589
601
  output += `**Status:** ${fields.status?.name || "Unknown"}\n`;
@@ -592,6 +604,15 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
592
604
  output += `**Assignee:** ${fields.assignee?.displayName || "Unassigned"}\n`;
593
605
  output += `**Reporter:** ${fields.reporter?.displayName || "Unknown"}\n`;
594
606
 
607
+ // Date fields
608
+ if (fields.created) output += `**Created:** ${fields.created}\n`;
609
+ if (fields.updated) output += `**Updated:** ${fields.updated}\n`;
610
+ if (fields.resolutiondate) output += `**Resolved:** ${fields.resolutiondate}\n`;
611
+ if (fields.resolution) output += `**Resolution:** ${fields.resolution.name}\n`;
612
+
613
+ // Story points
614
+ if (storyPoints != null) output += `**Story Points:** ${storyPoints}\n`;
615
+
595
616
  if (fields.sprint) {
596
617
  output += `**Sprint:** ${fields.sprint.name}\n`;
597
618
  }
@@ -600,6 +621,16 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
600
621
  output += `**Parent:** ${fields.parent.key} - ${fields.parent.fields?.summary || ""}\n`;
601
622
  }
602
623
 
624
+ // Labels, Components, Fix Versions
625
+ if (fields.labels?.length > 0) output += `**Labels:** ${fields.labels.join(", ")}\n`;
626
+ if (fields.components?.length > 0) output += `**Components:** ${fields.components.map(c => c.name).join(", ")}\n`;
627
+ if (fields.fixVersions?.length > 0) output += `**Fix Versions:** ${fields.fixVersions.map(v => v.name).join(", ")}\n`;
628
+
629
+ // Time tracking
630
+ if (fields.timetracking && (fields.timetracking.originalEstimate || fields.timetracking.timeSpent)) {
631
+ output += `**Time Tracking:** estimate=${fields.timetracking.originalEstimate || "none"}, spent=${fields.timetracking.timeSpent || "none"}, remaining=${fields.timetracking.remainingEstimate || "none"}\n`;
632
+ }
633
+
603
634
  // Subtasks
604
635
  if (fields.subtasks?.length > 0) {
605
636
  output += `**Subtasks:** ${fields.subtasks.length}\n`;
@@ -919,17 +950,202 @@ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
919
950
  return { text: output, jiraImages: downloadedImages, figmaDesigns };
920
951
  }
921
952
 
922
- async function searchTickets(jql, maxResults = 10) {
953
+ async function searchTickets(jql, maxResults = 10, fields = null) {
954
+ const defaultFields = [
955
+ "summary", "status", "assignee", "reporter", "issuetype", "priority",
956
+ "created", "resolutiondate", "updated", "statuscategorychangedate",
957
+ "resolution", "timetracking", "aggregatetimeoriginalestimate",
958
+ "aggregatetimespent", "parent", "labels", "components", "fixVersions",
959
+ "customfield_10016", // story points (Jira Software)
960
+ ];
961
+ const requestFields = fields || defaultFields;
923
962
  const data = await fetchJira(
924
- `/search/jql?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}`,
963
+ `/search/jql?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}&fields=${requestFields.join(",")}`,
925
964
  );
926
965
 
927
- let output = `# Search Results (${data.total} total, showing ${data.issues.length})\n\n`;
966
+ const issues = data.issues || [];
967
+ let output = `# Search Results (${data.total || 0} total, showing ${issues.length})\n\n`;
968
+
969
+ for (const issue of issues) {
970
+ const f = issue.fields || {};
971
+ const storyPoints = f.customfield_10016 ?? f.story_points ?? null;
972
+
973
+ output += `- **${issue.key}**: ${f.summary || "No summary"}\n`;
974
+ output += ` Status: ${f.status?.name || "Unknown"} | Type: ${f.issuetype?.name || "Unknown"} | Priority: ${f.priority?.name || "None"}\n`;
975
+ output += ` Assignee: ${f.assignee?.displayName || "Unassigned"} | Reporter: ${f.reporter?.displayName || "Unknown"}\n`;
976
+
977
+ if (f.created) output += ` Created: ${f.created}\n`;
978
+ if (f.updated) output += ` Updated: ${f.updated}\n`;
979
+ if (f.resolutiondate) output += ` Resolved: ${f.resolutiondate}\n`;
980
+ if (f.resolution) output += ` Resolution: ${f.resolution.name}\n`;
981
+ if (storyPoints != null) output += ` Story Points: ${storyPoints}\n`;
982
+ if (f.parent) output += ` Parent: ${f.parent.key}${f.parent.fields?.summary ? ` - ${f.parent.fields.summary}` : ""}\n`;
983
+ if (f.labels?.length > 0) output += ` Labels: ${f.labels.join(", ")}\n`;
984
+ if (f.components?.length > 0) output += ` Components: ${f.components.map(c => c.name).join(", ")}\n`;
985
+ if (f.fixVersions?.length > 0) output += ` Fix Versions: ${f.fixVersions.map(v => v.name).join(", ")}\n`;
986
+ if (f.timetracking && (f.timetracking.originalEstimate || f.timetracking.timeSpent)) {
987
+ output += ` Time Tracking: estimate=${f.timetracking.originalEstimate || "none"}, spent=${f.timetracking.timeSpent || "none"}, remaining=${f.timetracking.remainingEstimate || "none"}\n`;
988
+ }
928
989
 
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`;
990
+ output += "\n";
991
+ }
992
+
993
+ return output;
994
+ }
995
+
996
+ // ============ CHANGELOG FUNCTIONS ============
997
+
998
+ function parseStatusHistory(changelog) {
999
+ const statusChanges = [];
1000
+ if (!changelog?.histories) return statusChanges;
1001
+
1002
+ for (const history of changelog.histories) {
1003
+ for (const item of history.items || []) {
1004
+ if (item.field === "status") {
1005
+ statusChanges.push({
1006
+ from: item.fromString,
1007
+ to: item.toString,
1008
+ date: history.created,
1009
+ author: history.author?.displayName || "Unknown",
1010
+ });
1011
+ }
1012
+ }
1013
+ }
1014
+
1015
+ // Sort chronologically
1016
+ statusChanges.sort((a, b) => new Date(a.date) - new Date(b.date));
1017
+ return statusChanges;
1018
+ }
1019
+
1020
+ function computeMetrics(statusHistory, created) {
1021
+ if (!statusHistory.length) return null;
1022
+
1023
+ const metrics = {
1024
+ cycleTime: null,
1025
+ leadTime: null,
1026
+ timesInProgress: 0,
1027
+ timeInStatus: {},
1028
+ };
1029
+
1030
+ // Build timeline: start with created date in the initial status
1031
+ const timeline = [];
1032
+ if (created && statusHistory.length > 0) {
1033
+ timeline.push({ status: statusHistory[0].from, date: new Date(created) });
1034
+ }
1035
+ for (const change of statusHistory) {
1036
+ timeline.push({ status: change.to, date: new Date(change.date) });
1037
+ }
1038
+
1039
+ // Calculate time in each status
1040
+ for (let i = 0; i < timeline.length - 1; i++) {
1041
+ const status = timeline[i].status;
1042
+ const duration = timeline[i + 1].date - timeline[i].date;
1043
+ metrics.timeInStatus[status] = (metrics.timeInStatus[status] || 0) + duration;
1044
+ }
1045
+ // Add current status (time since last transition)
1046
+ const last = timeline[timeline.length - 1];
1047
+ const sinceLastTransition = Date.now() - last.date;
1048
+ metrics.timeInStatus[last.status] = (metrics.timeInStatus[last.status] || 0) + sinceLastTransition;
1049
+
1050
+ // Count times in progress-like statuses
1051
+ metrics.timesInProgress = statusHistory.filter(
1052
+ (c) => c.to.toLowerCase().includes("progress"),
1053
+ ).length;
1054
+
1055
+ // Cycle time: first "In Progress" to last "Done"
1056
+ const firstInProgress = statusHistory.find(
1057
+ (c) => c.to.toLowerCase().includes("progress"),
1058
+ );
1059
+ const lastDone = [...statusHistory].reverse().find(
1060
+ (c) => c.to.toLowerCase() === "done" || c.to.toLowerCase() === "closed",
1061
+ );
1062
+ if (firstInProgress && lastDone) {
1063
+ metrics.cycleTime = new Date(lastDone.date) - new Date(firstInProgress.date);
1064
+ }
1065
+
1066
+ // Lead time: created to done
1067
+ if (created && lastDone) {
1068
+ metrics.leadTime = new Date(lastDone.date) - new Date(created);
1069
+ }
1070
+
1071
+ // Format durations
1072
+ const fmt = (ms) => {
1073
+ if (ms == null) return null;
1074
+ const totalMinutes = Math.floor(ms / 60000);
1075
+ const days = Math.floor(totalMinutes / (60 * 24));
1076
+ const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
1077
+ const minutes = totalMinutes % 60;
1078
+ const parts = [];
1079
+ if (days > 0) parts.push(`${days}d`);
1080
+ if (hours > 0) parts.push(`${hours}h`);
1081
+ parts.push(`${minutes}m`);
1082
+ return parts.join(" ");
1083
+ };
1084
+
1085
+ return {
1086
+ cycleTime: fmt(metrics.cycleTime),
1087
+ leadTime: fmt(metrics.leadTime),
1088
+ timesInProgress: metrics.timesInProgress,
1089
+ timeInStatus: Object.fromEntries(
1090
+ Object.entries(metrics.timeInStatus).map(([k, v]) => [k, fmt(v)]),
1091
+ ),
1092
+ };
1093
+ }
1094
+
1095
+ function formatChangelog(issueKey, statusHistory, created) {
1096
+ let output = `## Status History for ${issueKey}\n\n`;
1097
+
1098
+ if (statusHistory.length === 0) {
1099
+ output += "_No status transitions found._\n";
1100
+ return output;
1101
+ }
1102
+
1103
+ for (const change of statusHistory) {
1104
+ const date = new Date(change.date).toLocaleString();
1105
+ output += `- **${change.from}** → **${change.to}** (${date}, by ${change.author})\n`;
1106
+ }
1107
+
1108
+ const computed = computeMetrics(statusHistory, created);
1109
+ if (computed) {
1110
+ output += `\n### Computed Metrics\n\n`;
1111
+ if (computed.cycleTime) output += `- **Cycle Time:** ${computed.cycleTime}\n`;
1112
+ if (computed.leadTime) output += `- **Lead Time:** ${computed.leadTime}\n`;
1113
+ output += `- **Times In Progress:** ${computed.timesInProgress}\n`;
1114
+ if (Object.keys(computed.timeInStatus).length > 0) {
1115
+ output += `- **Time in Status:**\n`;
1116
+ for (const [status, time] of Object.entries(computed.timeInStatus)) {
1117
+ output += ` - ${status}: ${time}\n`;
1118
+ }
1119
+ }
1120
+ }
1121
+
1122
+ return output;
1123
+ }
1124
+
1125
+ async function getChangelog(issueKey) {
1126
+ const issue = await fetchJira(`/issue/${issueKey}?expand=changelog&fields=created`);
1127
+ const statusHistory = parseStatusHistory(issue.changelog);
1128
+ const created = issue.fields?.created;
1129
+ return { statusHistory, created, formatted: formatChangelog(issueKey, statusHistory, created) };
1130
+ }
1131
+
1132
+ async function getChangelogsBulk(jql, maxResults = 50) {
1133
+ // First get the issue keys matching the JQL
1134
+ const data = await fetchJira(
1135
+ `/search/jql?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}&fields=key,created`,
1136
+ );
1137
+ const issues = data.issues || [];
1138
+
1139
+ let output = `# Changelogs (${issues.length} of ${data.total || 0} issues)\n\n`;
1140
+
1141
+ // Fetch changelog for each issue (JIRA search API doesn't support expand=changelog on /search/jql)
1142
+ for (const issue of issues) {
1143
+ try {
1144
+ const result = await getChangelog(issue.key);
1145
+ output += result.formatted + "\n";
1146
+ } catch (e) {
1147
+ output += `## ${issue.key}\n\n_Error fetching changelog: ${e.message}_\n\n`;
1148
+ }
933
1149
  }
934
1150
 
935
1151
  return output;
@@ -982,7 +1198,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
982
1198
  {
983
1199
  name: "jira_search",
984
1200
  description:
985
- "Search Jira tickets using JQL. Examples: 'project = MODS AND status = Open'",
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'",
986
1202
  inputSchema: {
987
1203
  type: "object",
988
1204
  properties: {
@@ -991,6 +1207,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
991
1207
  type: "number",
992
1208
  description: "Max results (default 10)",
993
1209
  },
1210
+ fields: {
1211
+ type: "array",
1212
+ items: { type: "string" },
1213
+ description:
1214
+ "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
+ },
994
1216
  },
995
1217
  required: ["jql"],
996
1218
  },
@@ -998,7 +1220,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
998
1220
  {
999
1221
  name: "jira_add_comment",
1000
1222
  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.",
1223
+ "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
1224
  inputSchema: {
1003
1225
  type: "object",
1004
1226
  properties: {
@@ -1101,7 +1323,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1101
1323
  {
1102
1324
  name: "jira_update_ticket",
1103
1325
  description:
1104
- "Update fields on a Jira ticket. IMPORTANT: Only pass the fields you want to change. Omitted fields are left untouched.",
1326
+ "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
1327
  inputSchema: {
1106
1328
  type: "object",
1107
1329
  properties: {
@@ -1171,6 +1393,32 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1171
1393
  required: ["query"],
1172
1394
  },
1173
1395
  },
1396
+ {
1397
+ name: "jira_get_changelog",
1398
+ 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.",
1400
+ inputSchema: {
1401
+ type: "object",
1402
+ properties: {
1403
+ issueKey: {
1404
+ type: "string",
1405
+ description:
1406
+ "Single issue key (e.g., MODS-13996). Use this OR jql, not both.",
1407
+ },
1408
+ jql: {
1409
+ type: "string",
1410
+ description:
1411
+ "JQL query to get changelogs for multiple tickets (e.g., 'project = MODS AND sprint = 123'). Use this OR issueKey, not both.",
1412
+ },
1413
+ maxResults: {
1414
+ type: "number",
1415
+ description:
1416
+ "Max results when using jql (default 50). Each ticket requires a separate API call, so keep this reasonable.",
1417
+ },
1418
+ },
1419
+ required: [],
1420
+ },
1421
+ },
1174
1422
  ],
1175
1423
  };
1176
1424
  });
@@ -1263,7 +1511,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1263
1511
 
1264
1512
  return { content };
1265
1513
  } else if (name === "jira_search") {
1266
- const result = await searchTickets(args.jql, args.maxResults || 10);
1514
+ const result = await searchTickets(args.jql, args.maxResults || 10, args.fields || null);
1267
1515
  return { content: [{ type: "text", text: result }] };
1268
1516
  } else if (name === "jira_add_comment") {
1269
1517
  // Build ADF content with mention support
@@ -1589,6 +1837,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1589
1837
  { type: "text", text: `Updated ${args.issueKey}: ${updated}.` },
1590
1838
  ],
1591
1839
  };
1840
+ } else if (name === "jira_get_changelog") {
1841
+ if (!args.issueKey && !args.jql) {
1842
+ return {
1843
+ content: [{ type: "text", text: "Error: Provide either issueKey or jql parameter." }],
1844
+ isError: true,
1845
+ };
1846
+ }
1847
+ if (args.issueKey) {
1848
+ const result = await getChangelog(args.issueKey);
1849
+ return { content: [{ type: "text", text: result.formatted }] };
1850
+ } else {
1851
+ const result = await getChangelogsBulk(args.jql, args.maxResults || 50);
1852
+ return { content: [{ type: "text", text: result }] };
1853
+ }
1592
1854
  } else {
1593
1855
  throw new Error(`Unknown tool: ${name}`);
1594
1856
  }
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.8",
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": {