@mcpio/jira 2.0.0 → 2.1.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/dist/index.js ADDED
@@ -0,0 +1,1105 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
5
+ import axios from 'axios';
6
+ import * as dotenv from 'dotenv';
7
+ dotenv.config();
8
+ function getRequiredEnv(name, fallback = null) {
9
+ const value = process.env[name];
10
+ if (value !== undefined && value !== '') {
11
+ return value;
12
+ }
13
+ if (fallback !== null && fallback !== undefined && fallback !== '') {
14
+ return fallback;
15
+ }
16
+ throw new Error(`Required environment variable ${name} is not set. Please check your .env file.`);
17
+ }
18
+ function validateIssueKey(key) {
19
+ if (!key || typeof key !== 'string') {
20
+ throw new Error('Invalid issue key: must be a string');
21
+ }
22
+ if (!/^[A-Z]+-\d+$/.test(key)) {
23
+ throw new Error(`Invalid issue key format: ${key}. Expected format: PROJECT-123`);
24
+ }
25
+ return key;
26
+ }
27
+ function validateProjectKey(key) {
28
+ if (!key || typeof key !== 'string') {
29
+ throw new Error('Invalid project key: must be a string');
30
+ }
31
+ if (!/^[A-Z][A-Z0-9_]{1,9}$/.test(key)) {
32
+ throw new Error(`Invalid project key format: ${key}. Expected 2-10 uppercase alphanumeric characters`);
33
+ }
34
+ return key;
35
+ }
36
+ function validateJQL(jql) {
37
+ if (!jql || typeof jql !== 'string') {
38
+ throw new Error('Invalid JQL query: must be a string');
39
+ }
40
+ if (jql.length > 5000) {
41
+ throw new Error('JQL query too long: maximum 5000 characters');
42
+ }
43
+ return jql;
44
+ }
45
+ function sanitizeString(str, maxLength = 1000, fieldName = 'input') {
46
+ if (!str || typeof str !== 'string') {
47
+ throw new Error(`Invalid ${fieldName}: must be a string`);
48
+ }
49
+ if (str.length > maxLength) {
50
+ throw new Error(`${fieldName} exceeds maximum length of ${maxLength} characters`);
51
+ }
52
+ return str.trim();
53
+ }
54
+ function validateSafeParam(str, fieldName, maxLength = 100) {
55
+ if (!str || typeof str !== 'string') {
56
+ throw new Error(`Invalid ${fieldName}: must be a non-empty string`);
57
+ }
58
+ if (str.length > maxLength) {
59
+ throw new Error(`${fieldName} exceeds maximum length of ${maxLength} characters`);
60
+ }
61
+ if (/[\/\\]/.test(str)) {
62
+ throw new Error(`Invalid ${fieldName}: contains unsafe characters`);
63
+ }
64
+ return str.trim();
65
+ }
66
+ function validateMaxResults(maxResults) {
67
+ if (typeof maxResults !== 'number' || !Number.isInteger(maxResults) || maxResults < 1) {
68
+ throw new Error('maxResults must be a positive integer');
69
+ }
70
+ return Math.min(maxResults, 100);
71
+ }
72
+ function validateStoryPoints(points) {
73
+ if (typeof points !== 'number' || points < 0 || points > 1000) {
74
+ throw new Error('Story points must be a number between 0 and 1000');
75
+ }
76
+ return points;
77
+ }
78
+ function validateLabels(labels) {
79
+ if (!Array.isArray(labels)) {
80
+ throw new Error('Labels must be an array');
81
+ }
82
+ return labels.map((label, index) => {
83
+ if (typeof label !== 'string') {
84
+ throw new Error(`Label at index ${index} must be a string`);
85
+ }
86
+ if (label.length > 255) {
87
+ throw new Error(`Label at index ${index} exceeds maximum length of 255 characters`);
88
+ }
89
+ return label;
90
+ });
91
+ }
92
+ const JIRA_URL = getRequiredEnv('JIRA_HOST', process.env.JIRA_URL ?? null);
93
+ const JIRA_EMAIL = getRequiredEnv('JIRA_EMAIL');
94
+ const JIRA_API_TOKEN = getRequiredEnv('JIRA_API_TOKEN');
95
+ const JIRA_PROJECT_KEY = validateProjectKey(process.env.JIRA_PROJECT_KEY || 'PROJ');
96
+ const STORY_POINTS_FIELD = process.env.JIRA_STORY_POINTS_FIELD || 'customfield_10016';
97
+ if (!JIRA_URL.startsWith('https://')) {
98
+ throw new Error('JIRA_HOST must use HTTPS protocol for security');
99
+ }
100
+ function createSuccessResponse(data) {
101
+ return {
102
+ content: [{
103
+ type: 'text',
104
+ text: JSON.stringify(data, null, 2),
105
+ }],
106
+ };
107
+ }
108
+ function createIssueUrl(issueKey) {
109
+ return `${JIRA_URL}/browse/${issueKey}`;
110
+ }
111
+ function handleError(error) {
112
+ const isDevelopment = process.env.NODE_ENV === 'development';
113
+ const axiosError = error;
114
+ const jiraErrors = axiosError.response?.data?.errorMessages;
115
+ const jiraFieldErrors = axiosError.response?.data?.errors;
116
+ const errorResponse = {
117
+ error: 'Operation failed',
118
+ message: (error instanceof Error ? error.message : undefined) || 'An unexpected error occurred',
119
+ };
120
+ if (jiraErrors?.length) {
121
+ errorResponse.jiraErrors = jiraErrors;
122
+ }
123
+ if (jiraFieldErrors && Object.keys(jiraFieldErrors).length > 0) {
124
+ errorResponse.fieldErrors = jiraFieldErrors;
125
+ }
126
+ if (isDevelopment && error instanceof Error) {
127
+ errorResponse.stack = error.stack;
128
+ }
129
+ return {
130
+ content: [{
131
+ type: 'text',
132
+ text: JSON.stringify(errorResponse, null, 2),
133
+ }],
134
+ isError: true,
135
+ };
136
+ }
137
+ const jiraApi = axios.create({
138
+ baseURL: `${JIRA_URL}/rest/api/3`,
139
+ auth: {
140
+ username: JIRA_EMAIL,
141
+ password: JIRA_API_TOKEN,
142
+ },
143
+ headers: {
144
+ 'Content-Type': 'application/json',
145
+ },
146
+ timeout: 30000,
147
+ });
148
+ const server = new Server({
149
+ name: 'jira-mcp-server',
150
+ version: '2.0.0',
151
+ }, {
152
+ capabilities: {
153
+ tools: {},
154
+ prompts: {},
155
+ },
156
+ });
157
+ function parseInlineContent(text) {
158
+ if (!text)
159
+ return [];
160
+ const parts = [];
161
+ const regex = /\*\*([^*]+)\*\*|~~([^~]+)~~|\*([^*]+)\*|\[([^\]]+)\]\(([^)]+)\)|\[([^\]]+)\|([^\]]+)\]|`([^`]+)`/g;
162
+ let lastIndex = 0;
163
+ let match;
164
+ while ((match = regex.exec(text)) !== null) {
165
+ if (match.index > lastIndex) {
166
+ parts.push({ type: 'text', text: text.substring(lastIndex, match.index) });
167
+ }
168
+ if (match[1] !== undefined) {
169
+ parts.push({ type: 'text', text: match[1], marks: [{ type: 'strong' }] });
170
+ }
171
+ else if (match[2] !== undefined) {
172
+ parts.push({ type: 'text', text: match[2], marks: [{ type: 'strike' }] });
173
+ }
174
+ else if (match[3] !== undefined) {
175
+ parts.push({ type: 'text', text: match[3], marks: [{ type: 'em' }] });
176
+ }
177
+ else if (match[4] !== undefined) {
178
+ parts.push({ type: 'text', text: match[4], marks: [{ type: 'link', attrs: { href: match[5] } }] });
179
+ }
180
+ else if (match[6] !== undefined) {
181
+ parts.push({ type: 'text', text: match[6], marks: [{ type: 'link', attrs: { href: match[7] } }] });
182
+ }
183
+ else if (match[8] !== undefined) {
184
+ parts.push({ type: 'text', text: match[8], marks: [{ type: 'code' }] });
185
+ }
186
+ lastIndex = regex.lastIndex;
187
+ }
188
+ if (lastIndex < text.length) {
189
+ parts.push({ type: 'text', text: text.substring(lastIndex) });
190
+ }
191
+ if (parts.length > 0)
192
+ return parts;
193
+ return text ? [{ type: 'text', text }] : [];
194
+ }
195
+ function addBulletItem(nodes, content) {
196
+ const listItem = {
197
+ type: 'listItem',
198
+ content: [{ type: 'paragraph', content }]
199
+ };
200
+ const lastNode = nodes[nodes.length - 1];
201
+ if (lastNode && lastNode.type === 'bulletList') {
202
+ lastNode.content.push(listItem);
203
+ }
204
+ else {
205
+ nodes.push({ type: 'bulletList', content: [listItem] });
206
+ }
207
+ }
208
+ function addOrderedItem(nodes, content) {
209
+ const listItem = {
210
+ type: 'listItem',
211
+ content: [{ type: 'paragraph', content }]
212
+ };
213
+ const lastNode = nodes[nodes.length - 1];
214
+ if (lastNode && lastNode.type === 'orderedList') {
215
+ lastNode.content.push(listItem);
216
+ }
217
+ else {
218
+ nodes.push({ type: 'orderedList', content: [listItem] });
219
+ }
220
+ }
221
+ function createADFDocument(content) {
222
+ if (!content || typeof content !== 'string') {
223
+ return {
224
+ type: 'doc',
225
+ version: 1,
226
+ content: [{ type: 'paragraph', content: [] }]
227
+ };
228
+ }
229
+ const nodes = [];
230
+ const lines = content.split('\n');
231
+ for (let i = 0; i < lines.length; i++) {
232
+ const line = lines[i].trim();
233
+ if (!line)
234
+ continue;
235
+ const jiraHeading = line.match(/^h([1-6])\.\s+(.+)/);
236
+ const mdHeading = line.match(/^(#{1,6})\s+(.+)/);
237
+ if (jiraHeading) {
238
+ nodes.push({
239
+ type: 'heading',
240
+ attrs: { level: parseInt(jiraHeading[1]) },
241
+ content: parseInlineContent(jiraHeading[2])
242
+ });
243
+ }
244
+ else if (mdHeading) {
245
+ nodes.push({
246
+ type: 'heading',
247
+ attrs: { level: mdHeading[1].length },
248
+ content: parseInlineContent(mdHeading[2])
249
+ });
250
+ }
251
+ else if (line.startsWith('* ') || line.startsWith('- ')) {
252
+ addBulletItem(nodes, parseInlineContent(line.substring(2)));
253
+ }
254
+ else if (/^\d+\.\s+/.test(line)) {
255
+ addOrderedItem(nodes, parseInlineContent(line.replace(/^\d+\.\s+/, '')));
256
+ }
257
+ else if (line.startsWith('> ')) {
258
+ const text = line.substring(2);
259
+ const lastNode = nodes[nodes.length - 1];
260
+ if (lastNode && lastNode.type === 'blockquote') {
261
+ lastNode.content.push({
262
+ type: 'paragraph',
263
+ content: parseInlineContent(text)
264
+ });
265
+ }
266
+ else {
267
+ nodes.push({
268
+ type: 'blockquote',
269
+ content: [{ type: 'paragraph', content: parseInlineContent(text) }]
270
+ });
271
+ }
272
+ }
273
+ else if (line === '----' || line === '---') {
274
+ nodes.push({ type: 'rule' });
275
+ }
276
+ else if (line === '```' || line.startsWith('```')) {
277
+ const lang = line.length > 3 ? line.substring(3).trim() : null;
278
+ const codeLines = [];
279
+ i++;
280
+ while (i < lines.length && lines[i].trim() !== '```') {
281
+ codeLines.push(lines[i]);
282
+ i++;
283
+ }
284
+ const codeText = codeLines.join('\n');
285
+ const codeBlock = { type: 'codeBlock' };
286
+ if (codeText) {
287
+ codeBlock.content = [{ type: 'text', text: codeText }];
288
+ }
289
+ if (lang) {
290
+ codeBlock.attrs = { language: lang };
291
+ }
292
+ nodes.push(codeBlock);
293
+ }
294
+ else {
295
+ nodes.push({
296
+ type: 'paragraph',
297
+ content: parseInlineContent(line)
298
+ });
299
+ }
300
+ }
301
+ if (nodes.length === 0) {
302
+ nodes.push({ type: 'paragraph', content: [] });
303
+ }
304
+ return {
305
+ type: 'doc',
306
+ version: 1,
307
+ content: nodes
308
+ };
309
+ }
310
+ function inlineNodesToText(nodes) {
311
+ if (!Array.isArray(nodes))
312
+ return '';
313
+ return nodes.map(node => {
314
+ if (node.type === 'text') {
315
+ let text = node.text || '';
316
+ if (node.marks) {
317
+ for (const mark of node.marks) {
318
+ switch (mark.type) {
319
+ case 'strong':
320
+ text = `**${text}**`;
321
+ break;
322
+ case 'em':
323
+ text = `*${text}*`;
324
+ break;
325
+ case 'strike':
326
+ text = `~~${text}~~`;
327
+ break;
328
+ case 'code':
329
+ text = `\`${text}\``;
330
+ break;
331
+ case 'link':
332
+ text = `[${text}](${mark.attrs?.href || ''})`;
333
+ break;
334
+ }
335
+ }
336
+ }
337
+ return text;
338
+ }
339
+ if (node.type === 'hardBreak')
340
+ return '\n';
341
+ if (node.type === 'mention')
342
+ return `@${node.attrs?.text || node.attrs?.id || ''}`;
343
+ if (node.type === 'inlineCard')
344
+ return node.attrs?.url || '';
345
+ if (node.type === 'emoji')
346
+ return node.attrs?.shortName || '';
347
+ return '';
348
+ }).join('');
349
+ }
350
+ function blockNodeToText(node) {
351
+ if (!node)
352
+ return '';
353
+ switch (node.type) {
354
+ case 'paragraph':
355
+ return inlineNodesToText(node.content);
356
+ case 'heading': {
357
+ const level = node.attrs?.level || 1;
358
+ return '#'.repeat(level) + ' ' + inlineNodesToText(node.content);
359
+ }
360
+ case 'bulletList':
361
+ return (node.content || []).map(item => '- ' + (item.content || []).map(c => blockNodeToText(c)).join('\n')).join('\n');
362
+ case 'orderedList':
363
+ return (node.content || []).map((item, i) => `${i + 1}. ` + (item.content || []).map(c => blockNodeToText(c)).join('\n')).join('\n');
364
+ case 'blockquote':
365
+ return (node.content || []).map(c => '> ' + blockNodeToText(c)).join('\n');
366
+ case 'codeBlock': {
367
+ const lang = node.attrs?.language || '';
368
+ const code = inlineNodesToText(node.content);
369
+ return '```' + lang + '\n' + code + '\n```';
370
+ }
371
+ case 'rule':
372
+ return '---';
373
+ case 'table':
374
+ return (node.content || []).map(row => '| ' + (row.content || []).map(cell => (cell.content || []).map(c => blockNodeToText(c)).join(' ')).join(' | ') + ' |').join('\n');
375
+ case 'mediaSingle':
376
+ case 'mediaGroup':
377
+ return '[media]';
378
+ default:
379
+ return inlineNodesToText(node.content);
380
+ }
381
+ }
382
+ function adfToText(doc) {
383
+ if (!doc || typeof doc !== 'object' || doc.type !== 'doc' || !Array.isArray(doc.content)) {
384
+ return typeof doc === 'string' ? doc : '';
385
+ }
386
+ return doc.content.map(node => blockNodeToText(node)).join('\n\n');
387
+ }
388
+ server.setRequestHandler(ListPromptsRequestSchema, async () => {
389
+ return {
390
+ prompts: [
391
+ {
392
+ name: 'jira-formatting-guide',
393
+ description: 'Essential Jira formatting rules for creating clickable links and properly formatted issues',
394
+ },
395
+ ],
396
+ };
397
+ });
398
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
399
+ if (request.params.name === 'jira-formatting-guide') {
400
+ return {
401
+ messages: [
402
+ {
403
+ role: 'user',
404
+ content: {
405
+ type: 'text',
406
+ text: `This MCP server automatically converts Markdown to Atlassian Document Format (ADF).
407
+
408
+ Use standard Markdown:
409
+
410
+ Headings: # H1, ## H2, ### H3, #### H4, ##### H5, ###### H6
411
+ Bold: **bold text**
412
+ Italic: *italic text*
413
+ Strikethrough: ~~deleted text~~
414
+ Inline code: \`code\`
415
+ Links: [text](https://example.com)
416
+ Bullet lists: - item
417
+ Numbered lists: 1. item
418
+ Blockquotes: > text
419
+ Code blocks: \`\`\`language ... \`\`\`
420
+ Horizontal rule: ---
421
+
422
+ When referencing Jira issues, always use clickable links:
423
+ [PROJ-123](https://your-domain.atlassian.net/browse/PROJ-123)`,
424
+ },
425
+ },
426
+ ],
427
+ };
428
+ }
429
+ throw new Error(`Unknown prompt: ${request.params.name}`);
430
+ });
431
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
432
+ return {
433
+ tools: [
434
+ {
435
+ name: 'jira_create_issue',
436
+ description: 'Create a new Jira issue. Description supports standard Markdown (headings, **bold**, [links](url), lists, code blocks). Automatically converted to ADF.',
437
+ inputSchema: {
438
+ type: 'object',
439
+ properties: {
440
+ summary: { type: 'string', description: 'Issue summary/title' },
441
+ description: { type: 'string', description: 'Issue description in Markdown. Use [KEY](url) for clickable issue links.' },
442
+ issueType: { type: 'string', description: 'Issue type (Story, Task, Bug, etc.)', default: 'Task' },
443
+ priority: { type: 'string', description: 'Priority (Highest, High, Medium, Low, Lowest)', default: 'Medium' },
444
+ labels: { type: 'array', items: { type: 'string' }, description: 'Labels for the issue' },
445
+ storyPoints: { type: 'number', description: 'Story points estimate (0-1000)' },
446
+ projectKey: { type: 'string', description: 'Project key (defaults to configured JIRA_PROJECT_KEY)' },
447
+ },
448
+ required: ['summary', 'description'],
449
+ },
450
+ },
451
+ {
452
+ name: 'jira_get_issue',
453
+ description: 'Get details of a Jira issue',
454
+ inputSchema: {
455
+ type: 'object',
456
+ properties: {
457
+ issueKey: { type: 'string', description: 'Issue key (e.g., TTC-123)' },
458
+ },
459
+ required: ['issueKey'],
460
+ },
461
+ },
462
+ {
463
+ name: 'jira_search_issues',
464
+ description: 'Search for Jira issues using JQL',
465
+ inputSchema: {
466
+ type: 'object',
467
+ properties: {
468
+ jql: { type: 'string', description: 'JQL query string' },
469
+ maxResults: { type: 'number', description: 'Maximum number of results (1-100)', default: 50 },
470
+ },
471
+ required: ['jql'],
472
+ },
473
+ },
474
+ {
475
+ name: 'jira_update_issue',
476
+ description: 'Update a Jira issue. Description supports standard Markdown (headings, **bold**, [links](url), lists, code blocks). Automatically converted to ADF.',
477
+ inputSchema: {
478
+ type: 'object',
479
+ properties: {
480
+ issueKey: { type: 'string', description: 'Issue key to update' },
481
+ summary: { type: 'string', description: 'New summary' },
482
+ description: { type: 'string', description: 'New description in Markdown. Use [KEY](url) for clickable issue links.' },
483
+ status: { type: 'string', description: 'New status (To Do, In Progress, Done, etc.)' },
484
+ },
485
+ required: ['issueKey'],
486
+ },
487
+ },
488
+ {
489
+ name: 'jira_add_comment',
490
+ description: 'Add a comment to a Jira issue. Supports standard Markdown, automatically converted to ADF.',
491
+ inputSchema: {
492
+ type: 'object',
493
+ properties: {
494
+ issueKey: { type: 'string', description: 'Issue key' },
495
+ comment: { type: 'string', description: 'Comment text in Markdown.' },
496
+ },
497
+ required: ['issueKey', 'comment'],
498
+ },
499
+ },
500
+ {
501
+ name: 'jira_link_issues',
502
+ description: 'Create a link between two issues. IMPORTANT: When linking multiple issues, use sequential calls (2-3 at a time max) instead of parallel calls to avoid permission prompt issues in Claude Code.',
503
+ inputSchema: {
504
+ type: 'object',
505
+ properties: {
506
+ inwardIssue: { type: 'string', description: 'Issue key that will be linked from (e.g., TTC-260)' },
507
+ outwardIssue: { type: 'string', description: 'Issue key that will be linked to (e.g., TTC-87)' },
508
+ linkType: { type: 'string', description: 'Link type (Relates, Blocks, Cloners, Duplicate, etc.)', default: 'Relates' },
509
+ },
510
+ required: ['inwardIssue', 'outwardIssue'],
511
+ },
512
+ },
513
+ {
514
+ name: 'jira_get_project_info',
515
+ description: 'Get project information',
516
+ inputSchema: {
517
+ type: 'object',
518
+ properties: {
519
+ projectKey: { type: 'string', description: 'Project key', default: JIRA_PROJECT_KEY },
520
+ },
521
+ },
522
+ },
523
+ {
524
+ name: 'jira_delete_issue',
525
+ description: 'Delete a Jira issue',
526
+ inputSchema: {
527
+ type: 'object',
528
+ properties: {
529
+ issueKey: { type: 'string', description: 'Issue key to delete (e.g., TTC-123)' },
530
+ },
531
+ required: ['issueKey'],
532
+ },
533
+ },
534
+ {
535
+ name: 'jira_create_subtask',
536
+ description: 'Create a subtask under a parent issue. Description supports standard Markdown, automatically converted to ADF.',
537
+ inputSchema: {
538
+ type: 'object',
539
+ properties: {
540
+ parentKey: { type: 'string', description: 'Parent issue key (e.g., TTC-261)' },
541
+ summary: { type: 'string', description: 'Subtask summary/title' },
542
+ description: { type: 'string', description: 'Subtask description in Markdown. Use [KEY](url) for clickable issue links.' },
543
+ priority: { type: 'string', description: 'Priority (Highest, High, Medium, Low, Lowest)', default: 'Medium' },
544
+ projectKey: { type: 'string', description: 'Project key (defaults to configured JIRA_PROJECT_KEY)' },
545
+ },
546
+ required: ['parentKey', 'summary', 'description'],
547
+ },
548
+ },
549
+ {
550
+ name: 'jira_assign_issue',
551
+ description: 'Assign or unassign a user to a Jira issue. Pass null accountId to unassign.',
552
+ inputSchema: {
553
+ type: 'object',
554
+ properties: {
555
+ issueKey: { type: 'string', description: 'Issue key (e.g., TTC-123)' },
556
+ accountId: { type: ['string', 'null'], description: 'Atlassian account ID of the assignee, or null to unassign' },
557
+ },
558
+ required: ['issueKey'],
559
+ },
560
+ },
561
+ {
562
+ name: 'jira_list_transitions',
563
+ description: 'Get available status transitions for a Jira issue.',
564
+ inputSchema: {
565
+ type: 'object',
566
+ properties: {
567
+ issueKey: { type: 'string', description: 'Issue key (e.g., TTC-123)' },
568
+ },
569
+ required: ['issueKey'],
570
+ },
571
+ },
572
+ {
573
+ name: 'jira_add_worklog',
574
+ description: 'Add a worklog entry (time tracking) to a Jira issue.',
575
+ inputSchema: {
576
+ type: 'object',
577
+ properties: {
578
+ issueKey: { type: 'string', description: 'Issue key (e.g., TTC-123)' },
579
+ timeSpent: { type: 'string', description: 'Time spent in Jira format (e.g., "2h 30m", "1d", "45m")' },
580
+ comment: { type: 'string', description: 'Worklog comment in Markdown.' },
581
+ started: { type: 'string', description: 'Start date/time in ISO 8601 format (e.g., "2024-01-15T09:00:00.000+0000"). Defaults to now.' },
582
+ },
583
+ required: ['issueKey', 'timeSpent'],
584
+ },
585
+ },
586
+ {
587
+ name: 'jira_get_comments',
588
+ description: 'Get comments from a Jira issue.',
589
+ inputSchema: {
590
+ type: 'object',
591
+ properties: {
592
+ issueKey: { type: 'string', description: 'Issue key (e.g., TTC-123)' },
593
+ maxResults: { type: 'number', description: 'Maximum number of comments (1-100)', default: 50 },
594
+ orderBy: { type: 'string', description: 'Order by created date: "created" (oldest first) or "-created" (newest first)', default: '-created' },
595
+ },
596
+ required: ['issueKey'],
597
+ },
598
+ },
599
+ {
600
+ name: 'jira_get_worklogs',
601
+ description: 'Get worklog entries from a Jira issue.',
602
+ inputSchema: {
603
+ type: 'object',
604
+ properties: {
605
+ issueKey: { type: 'string', description: 'Issue key (e.g., TTC-123)' },
606
+ },
607
+ required: ['issueKey'],
608
+ },
609
+ },
610
+ {
611
+ name: 'jira_list_projects',
612
+ description: 'List all accessible Jira projects.',
613
+ inputSchema: {
614
+ type: 'object',
615
+ properties: {
616
+ maxResults: { type: 'number', description: 'Maximum number of results (1-100)', default: 50 },
617
+ query: { type: 'string', description: 'Filter projects by name (partial match)' },
618
+ },
619
+ },
620
+ },
621
+ {
622
+ name: 'jira_get_project_components',
623
+ description: 'Get components of a Jira project.',
624
+ inputSchema: {
625
+ type: 'object',
626
+ properties: {
627
+ projectKey: { type: 'string', description: 'Project key (defaults to configured JIRA_PROJECT_KEY)' },
628
+ },
629
+ },
630
+ },
631
+ {
632
+ name: 'jira_get_project_versions',
633
+ description: 'Get versions (releases) of a Jira project.',
634
+ inputSchema: {
635
+ type: 'object',
636
+ properties: {
637
+ projectKey: { type: 'string', description: 'Project key (defaults to configured JIRA_PROJECT_KEY)' },
638
+ },
639
+ },
640
+ },
641
+ {
642
+ name: 'jira_get_fields',
643
+ description: 'Get all available Jira fields. Useful for finding custom field IDs.',
644
+ inputSchema: { type: 'object', properties: {} },
645
+ },
646
+ {
647
+ name: 'jira_get_issue_types',
648
+ description: 'Get all available issue types for a project.',
649
+ inputSchema: {
650
+ type: 'object',
651
+ properties: {
652
+ projectKey: { type: 'string', description: 'Project key (defaults to configured JIRA_PROJECT_KEY)' },
653
+ },
654
+ },
655
+ },
656
+ {
657
+ name: 'jira_get_priorities',
658
+ description: 'Get all available issue priorities.',
659
+ inputSchema: { type: 'object', properties: {} },
660
+ },
661
+ {
662
+ name: 'jira_get_link_types',
663
+ description: 'Get all available issue link types.',
664
+ inputSchema: { type: 'object', properties: {} },
665
+ },
666
+ {
667
+ name: 'jira_search_users',
668
+ description: 'Search for Jira users by name or email. Returns accountId needed for jira_assign_issue.',
669
+ inputSchema: {
670
+ type: 'object',
671
+ properties: {
672
+ query: { type: 'string', description: 'Search query (matches display name and email prefix)' },
673
+ maxResults: { type: 'number', description: 'Maximum number of results (1-100)', default: 10 },
674
+ },
675
+ required: ['query'],
676
+ },
677
+ },
678
+ ],
679
+ };
680
+ });
681
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
682
+ const { name, arguments: args } = request.params;
683
+ const a = args;
684
+ try {
685
+ switch (name) {
686
+ case 'jira_create_issue': {
687
+ const { summary, description, issueType = 'Task', priority = 'Medium', labels = [], storyPoints } = a;
688
+ const projectKey = a.projectKey ? validateProjectKey(a.projectKey) : JIRA_PROJECT_KEY;
689
+ validateSafeParam(issueType, 'issueType');
690
+ validateSafeParam(priority, 'priority');
691
+ const validatedLabels = validateLabels(labels);
692
+ const issueData = {
693
+ fields: {
694
+ project: { key: projectKey },
695
+ summary: sanitizeString(summary, 500, 'summary'),
696
+ description: createADFDocument(description),
697
+ issuetype: { name: issueType },
698
+ priority: { name: priority },
699
+ labels: validatedLabels,
700
+ },
701
+ };
702
+ if (storyPoints !== undefined && storyPoints !== null) {
703
+ issueData.fields[STORY_POINTS_FIELD] = validateStoryPoints(storyPoints);
704
+ }
705
+ const response = await jiraApi.post('/issue', issueData);
706
+ return createSuccessResponse({
707
+ success: true,
708
+ key: response.data.key,
709
+ id: response.data.id,
710
+ url: createIssueUrl(response.data.key),
711
+ });
712
+ }
713
+ case 'jira_get_issue': {
714
+ const { issueKey } = a;
715
+ validateIssueKey(issueKey);
716
+ const response = await jiraApi.get(`/issue/${issueKey}`);
717
+ const f = response.data.fields;
718
+ return createSuccessResponse({
719
+ key: response.data.key,
720
+ summary: f.summary,
721
+ description: adfToText(f.description),
722
+ status: f.status?.name,
723
+ assignee: f.assignee ? { displayName: f.assignee.displayName, accountId: f.assignee.accountId } : null,
724
+ reporter: f.reporter?.displayName,
725
+ priority: f.priority?.name,
726
+ issueType: f.issuetype?.name,
727
+ labels: f.labels || [],
728
+ storyPoints: f[STORY_POINTS_FIELD],
729
+ parent: f.parent?.key,
730
+ created: f.created,
731
+ updated: f.updated,
732
+ url: createIssueUrl(response.data.key),
733
+ });
734
+ }
735
+ case 'jira_search_issues': {
736
+ const { jql, maxResults = 50 } = a;
737
+ validateJQL(jql);
738
+ const validatedMaxResults = validateMaxResults(maxResults);
739
+ const response = await jiraApi.post('/search', {
740
+ jql,
741
+ maxResults: validatedMaxResults,
742
+ fields: ['summary', 'status', 'assignee', 'priority', 'created', 'updated', 'issuetype', 'parent', 'labels'],
743
+ });
744
+ return createSuccessResponse({
745
+ total: response.data.total,
746
+ issues: response.data.issues.map((issue) => ({
747
+ key: issue.key,
748
+ summary: issue.fields.summary,
749
+ status: issue.fields.status?.name,
750
+ assignee: issue.fields.assignee ? { displayName: issue.fields.assignee.displayName, accountId: issue.fields.assignee.accountId } : null,
751
+ priority: issue.fields.priority?.name,
752
+ issueType: issue.fields.issuetype?.name,
753
+ labels: issue.fields.labels || [],
754
+ parent: issue.fields.parent?.key,
755
+ url: createIssueUrl(issue.key),
756
+ })),
757
+ });
758
+ }
759
+ case 'jira_update_issue': {
760
+ const { issueKey, summary, description, status } = a;
761
+ validateIssueKey(issueKey);
762
+ const updateData = { fields: {} };
763
+ let hasFieldUpdates = false;
764
+ if (summary) {
765
+ updateData.fields.summary = sanitizeString(summary, 500, 'summary');
766
+ hasFieldUpdates = true;
767
+ }
768
+ if (description) {
769
+ updateData.fields.description = createADFDocument(description);
770
+ hasFieldUpdates = true;
771
+ }
772
+ if (hasFieldUpdates) {
773
+ await jiraApi.put(`/issue/${issueKey}`, updateData);
774
+ }
775
+ const warnings = [];
776
+ if (status) {
777
+ const transitions = await jiraApi.get(`/issue/${issueKey}/transitions`);
778
+ const transition = transitions.data.transitions.find((t) => t.name === status);
779
+ if (transition) {
780
+ await jiraApi.post(`/issue/${issueKey}/transitions`, {
781
+ transition: { id: transition.id },
782
+ });
783
+ }
784
+ else {
785
+ const available = transitions.data.transitions.map((t) => t.name).join(', ');
786
+ warnings.push(`Transition "${status}" not found. Available transitions: ${available}`);
787
+ }
788
+ }
789
+ if (!hasFieldUpdates && !status) {
790
+ return createSuccessResponse({
791
+ success: false,
792
+ message: `No updates provided for ${issueKey}`,
793
+ });
794
+ }
795
+ const result = {
796
+ success: warnings.length === 0,
797
+ message: `Issue ${issueKey} updated${warnings.length > 0 ? ' with warnings' : ' successfully'}`,
798
+ url: createIssueUrl(issueKey),
799
+ };
800
+ if (warnings.length > 0) {
801
+ result.warnings = warnings;
802
+ }
803
+ return createSuccessResponse(result);
804
+ }
805
+ case 'jira_add_comment': {
806
+ const { issueKey, comment } = a;
807
+ validateIssueKey(issueKey);
808
+ await jiraApi.post(`/issue/${issueKey}/comment`, {
809
+ body: createADFDocument(comment),
810
+ });
811
+ return createSuccessResponse({
812
+ success: true,
813
+ message: `Comment added to ${issueKey}`,
814
+ });
815
+ }
816
+ case 'jira_link_issues': {
817
+ const { inwardIssue, outwardIssue, linkType = 'Relates' } = a;
818
+ validateIssueKey(inwardIssue);
819
+ validateIssueKey(outwardIssue);
820
+ validateSafeParam(linkType, 'linkType');
821
+ try {
822
+ await jiraApi.post('/issueLink', {
823
+ type: { name: linkType },
824
+ inwardIssue: { key: inwardIssue },
825
+ outwardIssue: { key: outwardIssue },
826
+ });
827
+ return createSuccessResponse({
828
+ success: true,
829
+ message: `Linked ${inwardIssue} to ${outwardIssue} with type "${linkType}"`,
830
+ });
831
+ }
832
+ catch (linkError) {
833
+ const axiosErr = linkError;
834
+ if (axiosErr.response?.status === 400 &&
835
+ axiosErr.response?.data?.errorMessages?.includes('link already exists')) {
836
+ return createSuccessResponse({
837
+ success: true,
838
+ message: `Link between ${inwardIssue} and ${outwardIssue} already exists`,
839
+ alreadyLinked: true,
840
+ });
841
+ }
842
+ throw linkError;
843
+ }
844
+ }
845
+ case 'jira_get_project_info': {
846
+ const { projectKey = JIRA_PROJECT_KEY } = a;
847
+ validateProjectKey(projectKey);
848
+ const response = await jiraApi.get(`/project/${projectKey}`);
849
+ return createSuccessResponse({
850
+ key: response.data.key,
851
+ name: response.data.name,
852
+ description: response.data.description,
853
+ lead: response.data.lead?.displayName,
854
+ url: response.data.url,
855
+ });
856
+ }
857
+ case 'jira_delete_issue': {
858
+ const { issueKey } = a;
859
+ validateIssueKey(issueKey);
860
+ await jiraApi.delete(`/issue/${issueKey}`);
861
+ return createSuccessResponse({
862
+ success: true,
863
+ message: `Issue ${issueKey} deleted successfully`,
864
+ });
865
+ }
866
+ case 'jira_create_subtask': {
867
+ const { parentKey, summary, description, priority = 'Medium' } = a;
868
+ validateIssueKey(parentKey);
869
+ validateSafeParam(priority, 'priority');
870
+ const projectKey = a.projectKey ? validateProjectKey(a.projectKey) : JIRA_PROJECT_KEY;
871
+ const issueData = {
872
+ fields: {
873
+ project: { key: projectKey },
874
+ summary: sanitizeString(summary, 500, 'summary'),
875
+ description: createADFDocument(description),
876
+ issuetype: { name: 'Subtask' },
877
+ priority: { name: priority },
878
+ parent: { key: parentKey },
879
+ },
880
+ };
881
+ const response = await jiraApi.post('/issue', issueData);
882
+ return createSuccessResponse({
883
+ success: true,
884
+ key: response.data.key,
885
+ id: response.data.id,
886
+ parent: parentKey,
887
+ url: createIssueUrl(response.data.key),
888
+ });
889
+ }
890
+ case 'jira_assign_issue': {
891
+ const { issueKey, accountId } = a;
892
+ validateIssueKey(issueKey);
893
+ await jiraApi.put(`/issue/${issueKey}/assignee`, {
894
+ accountId: accountId !== undefined ? accountId : null,
895
+ });
896
+ return createSuccessResponse({
897
+ success: true,
898
+ message: accountId
899
+ ? `Issue ${issueKey} assigned to ${accountId}`
900
+ : `Issue ${issueKey} unassigned`,
901
+ url: createIssueUrl(issueKey),
902
+ });
903
+ }
904
+ case 'jira_list_transitions': {
905
+ const { issueKey } = a;
906
+ validateIssueKey(issueKey);
907
+ const response = await jiraApi.get(`/issue/${issueKey}/transitions`);
908
+ return createSuccessResponse({
909
+ issueKey,
910
+ transitions: response.data.transitions.map((t) => ({
911
+ id: t.id,
912
+ name: t.name,
913
+ to: {
914
+ id: t.to.id,
915
+ name: t.to.name,
916
+ category: t.to.statusCategory?.name,
917
+ },
918
+ })),
919
+ });
920
+ }
921
+ case 'jira_add_worklog': {
922
+ const { issueKey, timeSpent, comment, started } = a;
923
+ validateIssueKey(issueKey);
924
+ sanitizeString(timeSpent, 50, 'timeSpent');
925
+ const worklogData = { timeSpent };
926
+ if (comment) {
927
+ worklogData.comment = createADFDocument(comment);
928
+ }
929
+ if (started) {
930
+ worklogData.started = started;
931
+ }
932
+ const response = await jiraApi.post(`/issue/${issueKey}/worklog`, worklogData);
933
+ return createSuccessResponse({
934
+ success: true,
935
+ id: response.data.id,
936
+ issueKey,
937
+ timeSpent: response.data.timeSpent,
938
+ author: response.data.author?.displayName,
939
+ });
940
+ }
941
+ case 'jira_get_comments': {
942
+ const { issueKey, maxResults = 50, orderBy = '-created' } = a;
943
+ validateIssueKey(issueKey);
944
+ const validatedMaxResults = validateMaxResults(maxResults);
945
+ const response = await jiraApi.get(`/issue/${issueKey}/comment`, {
946
+ params: { maxResults: validatedMaxResults, orderBy },
947
+ });
948
+ return createSuccessResponse({
949
+ issueKey,
950
+ total: response.data.total,
951
+ comments: response.data.comments.map((c) => ({
952
+ id: c.id,
953
+ author: c.author?.displayName,
954
+ body: adfToText(c.body),
955
+ created: c.created,
956
+ updated: c.updated,
957
+ })),
958
+ });
959
+ }
960
+ case 'jira_get_worklogs': {
961
+ const { issueKey } = a;
962
+ validateIssueKey(issueKey);
963
+ const response = await jiraApi.get(`/issue/${issueKey}/worklog`);
964
+ return createSuccessResponse({
965
+ issueKey,
966
+ total: response.data.total,
967
+ worklogs: response.data.worklogs.map((w) => ({
968
+ id: w.id,
969
+ author: w.author?.displayName,
970
+ timeSpent: w.timeSpent,
971
+ timeSpentSeconds: w.timeSpentSeconds,
972
+ started: w.started,
973
+ comment: adfToText(w.comment),
974
+ })),
975
+ });
976
+ }
977
+ case 'jira_list_projects': {
978
+ const { maxResults = 50, query } = a;
979
+ const validatedMaxResults = validateMaxResults(maxResults);
980
+ const params = { maxResults: validatedMaxResults };
981
+ if (query) {
982
+ params.query = sanitizeString(query, 200, 'query');
983
+ }
984
+ const response = await jiraApi.get('/project/search', { params });
985
+ return createSuccessResponse({
986
+ total: response.data.total,
987
+ projects: response.data.values.map((p) => ({
988
+ key: p.key,
989
+ name: p.name,
990
+ projectTypeKey: p.projectTypeKey,
991
+ style: p.style,
992
+ lead: p.lead?.displayName,
993
+ })),
994
+ });
995
+ }
996
+ case 'jira_get_project_components': {
997
+ const projectKey = a?.projectKey ? validateProjectKey(a.projectKey) : JIRA_PROJECT_KEY;
998
+ const response = await jiraApi.get(`/project/${projectKey}/components`);
999
+ return createSuccessResponse({
1000
+ projectKey,
1001
+ components: response.data.map((c) => ({
1002
+ id: c.id,
1003
+ name: c.name,
1004
+ description: c.description,
1005
+ lead: c.lead?.displayName,
1006
+ assigneeType: c.assigneeType,
1007
+ })),
1008
+ });
1009
+ }
1010
+ case 'jira_get_project_versions': {
1011
+ const projectKey = a?.projectKey ? validateProjectKey(a.projectKey) : JIRA_PROJECT_KEY;
1012
+ const response = await jiraApi.get(`/project/${projectKey}/versions`);
1013
+ return createSuccessResponse({
1014
+ projectKey,
1015
+ versions: response.data.map((v) => ({
1016
+ id: v.id,
1017
+ name: v.name,
1018
+ description: v.description,
1019
+ released: v.released,
1020
+ archived: v.archived,
1021
+ releaseDate: v.releaseDate,
1022
+ startDate: v.startDate,
1023
+ })),
1024
+ });
1025
+ }
1026
+ case 'jira_get_fields': {
1027
+ const response = await jiraApi.get('/field');
1028
+ return createSuccessResponse({
1029
+ fields: response.data.map((f) => ({
1030
+ id: f.id,
1031
+ name: f.name,
1032
+ custom: f.custom,
1033
+ schema: f.schema,
1034
+ })),
1035
+ });
1036
+ }
1037
+ case 'jira_get_issue_types': {
1038
+ const projectKey = a?.projectKey ? validateProjectKey(a.projectKey) : JIRA_PROJECT_KEY;
1039
+ const response = await jiraApi.get(`/issue/createmeta/${projectKey}/issuetypes`);
1040
+ return createSuccessResponse({
1041
+ projectKey,
1042
+ issueTypes: response.data.issueTypes.map((t) => ({
1043
+ id: t.id,
1044
+ name: t.name,
1045
+ subtask: t.subtask,
1046
+ description: t.description,
1047
+ })),
1048
+ });
1049
+ }
1050
+ case 'jira_get_priorities': {
1051
+ const response = await jiraApi.get('/priority');
1052
+ return createSuccessResponse({
1053
+ priorities: response.data.map((p) => ({
1054
+ id: p.id,
1055
+ name: p.name,
1056
+ description: p.description,
1057
+ iconUrl: p.iconUrl,
1058
+ })),
1059
+ });
1060
+ }
1061
+ case 'jira_get_link_types': {
1062
+ const response = await jiraApi.get('/issueLinkType');
1063
+ return createSuccessResponse({
1064
+ linkTypes: response.data.issueLinkTypes.map((lt) => ({
1065
+ id: lt.id,
1066
+ name: lt.name,
1067
+ inward: lt.inward,
1068
+ outward: lt.outward,
1069
+ })),
1070
+ });
1071
+ }
1072
+ case 'jira_search_users': {
1073
+ const { query, maxResults = 10 } = a;
1074
+ sanitizeString(query, 200, 'query');
1075
+ const validatedMaxResults = validateMaxResults(maxResults);
1076
+ const response = await jiraApi.get('/user/search', {
1077
+ params: { query, maxResults: validatedMaxResults },
1078
+ });
1079
+ return createSuccessResponse({
1080
+ users: response.data.map((u) => ({
1081
+ accountId: u.accountId,
1082
+ displayName: u.displayName,
1083
+ emailAddress: u.emailAddress,
1084
+ active: u.active,
1085
+ accountType: u.accountType,
1086
+ })),
1087
+ });
1088
+ }
1089
+ default:
1090
+ throw new Error(`Unknown tool: ${name}`);
1091
+ }
1092
+ }
1093
+ catch (error) {
1094
+ return handleError(error);
1095
+ }
1096
+ });
1097
+ async function main() {
1098
+ const transport = new StdioServerTransport();
1099
+ await server.connect(transport);
1100
+ console.error('Jira MCP Server running on stdio');
1101
+ }
1102
+ main().catch((error) => {
1103
+ console.error('Fatal error in main():', error);
1104
+ process.exit(1);
1105
+ });