@abdelrahmanhsn/jira-mcp 1.2.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +1 -1
- package/server.js +253 -0
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server that co
|
|
|
13
13
|
| `get_my_standup` | Get a standup summary of tickets you updated since yesterday |
|
|
14
14
|
| `get_sprint_summary` | Get all sprint tickets grouped by status (Todo / In Progress / Done) |
|
|
15
15
|
| `search_tickets` | Search tickets with plain English or raw JQL |
|
|
16
|
+
| `get_context_for_pr` | Extract Jira ticket from a branch name and return a ready-to-use PR description block |
|
|
17
|
+
| `start_ticket` | **⚡ Full coding brief** — description, acceptance criteria, comments, branch name, implementation checklist, and PR template in one shot |
|
|
16
18
|
|
|
17
19
|
## Prerequisites
|
|
18
20
|
|
|
@@ -124,6 +126,8 @@ Once configured, you can ask your AI assistant:
|
|
|
124
126
|
- *"Give me my standup for today"*
|
|
125
127
|
- *"Summarize the active sprint — how many tickets are done vs in progress?"*
|
|
126
128
|
- *"Search for open bugs related to login"*
|
|
129
|
+
- *"Get PR context for branch STUD-17891-add-email-icon"*
|
|
130
|
+
- *"Start working on STUD-17891"* → get full brief, branch name, checklist and PR template instantly
|
|
127
131
|
|
|
128
132
|
## Security
|
|
129
133
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abdelrahmanhsn/jira-mcp",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.5.0",
|
|
5
5
|
"description": "MCP server for Jira — query your tickets, active sprints, and issue details from any AI IDE (GitHub Copilot, Cursor, Claude Desktop)",
|
|
6
6
|
"main": "server.js",
|
|
7
7
|
"bin": {
|
package/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import axios from "axios";
|
|
3
|
+
import { execSync } from "child_process";
|
|
3
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
6
|
import { z } from "zod";
|
|
@@ -191,6 +192,258 @@ server.tool(
|
|
|
191
192
|
}
|
|
192
193
|
);
|
|
193
194
|
|
|
195
|
+
// Tool: get_context_for_pr
|
|
196
|
+
server.tool(
|
|
197
|
+
"get_context_for_pr",
|
|
198
|
+
"Extract the Jira ticket key from a branch name, fetch its details and comments, and return a structured PR context block ready for AI to write a pull request description",
|
|
199
|
+
{
|
|
200
|
+
branch: z.string().describe("Git branch name, e.g. STUD-17891-add-email-icon or feature/STUD-17891"),
|
|
201
|
+
},
|
|
202
|
+
async ({ branch }) => {
|
|
203
|
+
// Extract issue key (e.g. STUD-17891) from anywhere in the branch name
|
|
204
|
+
const match = branch.match(/([A-Z][A-Z0-9]+-\d+)/i);
|
|
205
|
+
if (!match) {
|
|
206
|
+
return {
|
|
207
|
+
content: [{
|
|
208
|
+
type: "text",
|
|
209
|
+
text: `Could not find a Jira issue key in branch name: "${branch}". Expected format: PROJ-123 anywhere in the branch.`,
|
|
210
|
+
}],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const issueKey = match[1].toUpperCase();
|
|
215
|
+
|
|
216
|
+
const [issueRes, commentsRes] = await Promise.all([
|
|
217
|
+
jiraClient.get(`/issue/${issueKey}`, {
|
|
218
|
+
params: { fields: "summary,description,issuetype,priority,status,assignee,reporter,labels,components" },
|
|
219
|
+
}),
|
|
220
|
+
jiraClient.get(`/issue/${issueKey}/comment`, {
|
|
221
|
+
params: { maxResults: 10, orderBy: "-created" },
|
|
222
|
+
}),
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
const f = issueRes.data.fields;
|
|
226
|
+
|
|
227
|
+
// Extract plain text from Atlassian Document Format description
|
|
228
|
+
function extractText(node) {
|
|
229
|
+
if (!node) return "";
|
|
230
|
+
if (node.type === "text") return node.text || "";
|
|
231
|
+
if (node.content) return node.content.map(extractText).join(" ");
|
|
232
|
+
return "";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const description = extractText(f.description).trim() || "No description provided.";
|
|
236
|
+
|
|
237
|
+
const comments = (commentsRes.data.comments || []).map(c => ({
|
|
238
|
+
author: c.author?.displayName,
|
|
239
|
+
body: extractText(c.body).trim(),
|
|
240
|
+
created: c.created?.split("T")[0],
|
|
241
|
+
})).filter(c => c.body);
|
|
242
|
+
|
|
243
|
+
const context = {
|
|
244
|
+
issue_key: issueKey,
|
|
245
|
+
summary: f.summary,
|
|
246
|
+
type: f.issuetype?.name,
|
|
247
|
+
priority: f.priority?.name,
|
|
248
|
+
status: f.status?.name,
|
|
249
|
+
assignee: f.assignee?.displayName ?? "Unassigned",
|
|
250
|
+
reporter: f.reporter?.displayName,
|
|
251
|
+
labels: f.labels ?? [],
|
|
252
|
+
components: (f.components ?? []).map(c => c.name),
|
|
253
|
+
description,
|
|
254
|
+
recent_comments: comments,
|
|
255
|
+
pr_context: [
|
|
256
|
+
`## ${issueKey}: ${f.summary}`,
|
|
257
|
+
``,
|
|
258
|
+
`**Type:** ${f.issuetype?.name} | **Priority:** ${f.priority?.name} | **Status:** ${f.status?.name}`,
|
|
259
|
+
``,
|
|
260
|
+
`### What this PR does`,
|
|
261
|
+
description,
|
|
262
|
+
comments.length > 0 ? `\n### Discussion context\n${comments.map(c => `- **${c.author}** (${c.created}): ${c.body}`).join("\n")}` : "",
|
|
263
|
+
``,
|
|
264
|
+
`### Jira ticket`,
|
|
265
|
+
`https://${JIRA_DOMAIN}/browse/${issueKey}`,
|
|
266
|
+
].filter(Boolean).join("\n"),
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
return { content: [{ type: "text", text: JSON.stringify(context, null, 2) }] };
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// Tool: start_ticket
|
|
274
|
+
server.tool(
|
|
275
|
+
"start_ticket",
|
|
276
|
+
"Fully start a Jira ticket: self-assigns it, moves it to In Progress, creates the git branch in the given working directory, and returns the full coding brief with description, acceptance criteria, comments, and PR template — all in one shot",
|
|
277
|
+
{
|
|
278
|
+
issueKey: z.string().describe("The Jira issue key, e.g. STUD-17891"),
|
|
279
|
+
workingDir: z.string().describe("Absolute path to the git repo where the branch should be created, e.g. /Users/you/code/my-project"),
|
|
280
|
+
},
|
|
281
|
+
async ({ issueKey, workingDir }) => {
|
|
282
|
+
const actions = [];
|
|
283
|
+
|
|
284
|
+
// ── 1. Fetch ticket + comments + current user in parallel ────────────────
|
|
285
|
+
const [issueRes, commentsRes, myselfRes, transitionsRes] = await Promise.all([
|
|
286
|
+
jiraClient.get(`/issue/${issueKey}`, {
|
|
287
|
+
params: {
|
|
288
|
+
fields: "summary,description,issuetype,priority,status,assignee,reporter,labels,components,customfield_10016,customfield_10014,customfield_10021,subtasks,parent",
|
|
289
|
+
},
|
|
290
|
+
}),
|
|
291
|
+
jiraClient.get(`/issue/${issueKey}/comment`, {
|
|
292
|
+
params: { maxResults: 20, orderBy: "-created" },
|
|
293
|
+
}),
|
|
294
|
+
jiraClient.get("/myself"),
|
|
295
|
+
jiraClient.get(`/issue/${issueKey}/transitions`),
|
|
296
|
+
]);
|
|
297
|
+
|
|
298
|
+
const f = issueRes.data.fields;
|
|
299
|
+
const me = myselfRes.data;
|
|
300
|
+
|
|
301
|
+
// Extract plain text from Atlassian Document Format
|
|
302
|
+
function extractText(node) {
|
|
303
|
+
if (!node) return "";
|
|
304
|
+
if (node.type === "text") return node.text || "";
|
|
305
|
+
if (node.type === "hardBreak") return "\n";
|
|
306
|
+
if (node.content) return node.content.map(extractText).join(node.type === "paragraph" ? "\n" : " ");
|
|
307
|
+
return "";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const description = extractText(f.description).trim() || "No description provided.";
|
|
311
|
+
const acceptanceCriteria = extractText(
|
|
312
|
+
f["customfield_10016"] || f["customfield_10014"] || f["customfield_10021"] || null
|
|
313
|
+
).trim();
|
|
314
|
+
|
|
315
|
+
const comments = (commentsRes.data.comments || [])
|
|
316
|
+
.map(c => ({ author: c.author?.displayName, body: extractText(c.body).trim(), created: c.created?.split("T")[0] }))
|
|
317
|
+
.filter(c => c.body);
|
|
318
|
+
|
|
319
|
+
const subtasks = (f.subtasks || []).map(s => ({
|
|
320
|
+
key: s.key,
|
|
321
|
+
summary: s.fields?.summary,
|
|
322
|
+
status: s.fields?.status?.name,
|
|
323
|
+
}));
|
|
324
|
+
|
|
325
|
+
// ── 2. Self-assign the ticket ─────────────────────────────────────────────
|
|
326
|
+
try {
|
|
327
|
+
await jiraClient.put(`/issue/${issueKey}/assignee`, { accountId: me.accountId });
|
|
328
|
+
actions.push(`✅ Assigned to ${me.displayName}`);
|
|
329
|
+
} catch (e) {
|
|
330
|
+
actions.push(`⚠️ Could not assign: ${e.response?.data?.errorMessages?.[0] ?? e.message}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── 3. Transition to "In Progress" ────────────────────────────────────────
|
|
334
|
+
const transitions = transitionsRes.data.transitions || [];
|
|
335
|
+
const inProgress = transitions.find(t =>
|
|
336
|
+
/in.?progress|start|doing|in.?dev/i.test(t.name)
|
|
337
|
+
);
|
|
338
|
+
if (inProgress) {
|
|
339
|
+
try {
|
|
340
|
+
await jiraClient.post(`/issue/${issueKey}/transitions`, { transition: { id: inProgress.id } });
|
|
341
|
+
actions.push(`✅ Moved to "${inProgress.name}"`);
|
|
342
|
+
} catch (e) {
|
|
343
|
+
actions.push(`⚠️ Could not transition: ${e.response?.data?.errorMessages?.[0] ?? e.message}`);
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
const available = transitions.map(t => t.name).join(", ");
|
|
347
|
+
actions.push(`⚠️ No "In Progress" transition found. Available: ${available}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── 4. Create git branch ──────────────────────────────────────────────────
|
|
351
|
+
const slugifiedSummary = (f.summary || "")
|
|
352
|
+
.toLowerCase()
|
|
353
|
+
.replace(/[^a-z0-9\s-]/g, "")
|
|
354
|
+
.trim()
|
|
355
|
+
.replace(/\s+/g, "-")
|
|
356
|
+
.slice(0, 50)
|
|
357
|
+
.replace(/-+$/, "");
|
|
358
|
+
const branchName = `${issueKey.toLowerCase()}-${slugifiedSummary}`;
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
execSync(`git -C "${workingDir}" checkout -b "${branchName}"`, { stdio: "pipe" });
|
|
362
|
+
actions.push(`✅ Created and switched to branch: ${branchName}`);
|
|
363
|
+
} catch (e) {
|
|
364
|
+
const stderr = e.stderr?.toString() ?? "";
|
|
365
|
+
if (stderr.includes("already exists")) {
|
|
366
|
+
try {
|
|
367
|
+
execSync(`git -C "${workingDir}" checkout "${branchName}"`, { stdio: "pipe" });
|
|
368
|
+
actions.push(`✅ Branch already exists — switched to: ${branchName}`);
|
|
369
|
+
} catch {
|
|
370
|
+
actions.push(`⚠️ Branch exists but could not switch: ${stderr}`);
|
|
371
|
+
}
|
|
372
|
+
} else {
|
|
373
|
+
actions.push(`⚠️ Git error: ${stderr || e.message}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const ticketUrl = `https://${JIRA_DOMAIN}/browse/${issueKey}`;
|
|
378
|
+
|
|
379
|
+
const brief = [
|
|
380
|
+
`# 🎯 ${issueKey}: ${f.summary}`,
|
|
381
|
+
``,
|
|
382
|
+
`**Type:** ${f.issuetype?.name} | **Priority:** ${f.priority?.name}`,
|
|
383
|
+
`**Assignee:** ${me.displayName} | **Reporter:** ${f.reporter?.displayName}`,
|
|
384
|
+
f.labels?.length ? `**Labels:** ${f.labels.join(", ")}` : "",
|
|
385
|
+
f.components?.length ? `**Components:** ${f.components.map(c => c.name).join(", ")}` : "",
|
|
386
|
+
`**Ticket:** ${ticketUrl}`,
|
|
387
|
+
``,
|
|
388
|
+
`## ⚡ Actions Taken`,
|
|
389
|
+
...actions.map(a => `- ${a}`),
|
|
390
|
+
``,
|
|
391
|
+
`## 📋 Description`,
|
|
392
|
+
description,
|
|
393
|
+
acceptanceCriteria ? `\n## ✅ Acceptance Criteria\n${acceptanceCriteria}` : "",
|
|
394
|
+
subtasks.length > 0
|
|
395
|
+
? `\n## 🔀 Subtasks\n${subtasks.map(s => `- [${s.status === "Done" ? "x" : " "}] ${s.key}: ${s.summary} (${s.status})`).join("\n")}`
|
|
396
|
+
: "",
|
|
397
|
+
comments.length > 0
|
|
398
|
+
? `\n## 💬 Recent Comments\n${comments.slice(0, 5).map(c => `**${c.author}** (${c.created}):\n${c.body}`).join("\n\n---\n\n")}`
|
|
399
|
+
: "",
|
|
400
|
+
``,
|
|
401
|
+
`## 🚀 Implementation Checklist`,
|
|
402
|
+
`- [ ] Implement the feature/fix on branch \`${branchName}\``,
|
|
403
|
+
`- [ ] Write unit tests`,
|
|
404
|
+
`- [ ] Write integration tests if applicable`,
|
|
405
|
+
`- [ ] Run full test suite`,
|
|
406
|
+
`- [ ] Create PR with title: \`${issueKey}: ${f.summary}\``,
|
|
407
|
+
`- [ ] Link PR to ticket: ${ticketUrl}`,
|
|
408
|
+
``,
|
|
409
|
+
`## 📝 PR Description Template`,
|
|
410
|
+
`### What`,
|
|
411
|
+
description.split("\n")[0],
|
|
412
|
+
``,
|
|
413
|
+
`### Why`,
|
|
414
|
+
`Resolves ${issueKey} — ${ticketUrl}`,
|
|
415
|
+
``,
|
|
416
|
+
`### How`,
|
|
417
|
+
`<!-- Describe your implementation approach -->`,
|
|
418
|
+
``,
|
|
419
|
+
`### Testing`,
|
|
420
|
+
`- [ ] Unit tests added/updated`,
|
|
421
|
+
`- [ ] Integration tests added/updated`,
|
|
422
|
+
`- [ ] Manually tested`,
|
|
423
|
+
].filter(l => l !== null && l !== undefined).join("\n");
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
content: [{
|
|
427
|
+
type: "text",
|
|
428
|
+
text: JSON.stringify({
|
|
429
|
+
issue_key: issueKey,
|
|
430
|
+
summary: f.summary,
|
|
431
|
+
type: f.issuetype?.name,
|
|
432
|
+
priority: f.priority?.name,
|
|
433
|
+
branch_name: branchName,
|
|
434
|
+
ticket_url: ticketUrl,
|
|
435
|
+
actions_taken: actions,
|
|
436
|
+
description,
|
|
437
|
+
acceptance_criteria: acceptanceCriteria || null,
|
|
438
|
+
subtasks,
|
|
439
|
+
comments,
|
|
440
|
+
brief,
|
|
441
|
+
}, null, 2),
|
|
442
|
+
}],
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
);
|
|
446
|
+
|
|
194
447
|
// ── Start ────────────────────────────────────────────────────────────────────
|
|
195
448
|
const transport = new StdioServerTransport();
|
|
196
449
|
await server.connect(transport);
|