@zierocode/mcp-atlassian-cloud 2.6.1

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,1833 @@
1
+ import crypto from "node:crypto";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
+ import express from "express";
5
+ import { AtlassianManager } from "./atlassian.js";
6
+ import { z } from "zod";
7
+ const app = express();
8
+ app.use(express.json());
9
+ // CORS middleware for Anthropic domains (required for Claude Desktop/Web)
10
+ app.use((req, res, next) => {
11
+ const allowedOrigins = [
12
+ "https://claude.ai",
13
+ "https://claude.com",
14
+ "https://www.anthropic.com",
15
+ "https://api.anthropic.com",
16
+ ];
17
+ const origin = req.headers.origin;
18
+ if (origin && allowedOrigins.includes(origin)) {
19
+ res.setHeader("Access-Control-Allow-Origin", origin);
20
+ }
21
+ res.setHeader("Access-Control-Allow-Credentials", "true");
22
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, HEAD");
23
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Api-Key, Mcp-Session-Id, Accept");
24
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, Mcp-Protocol-Version");
25
+ if (req.method === "OPTIONS") {
26
+ res.status(204).end();
27
+ return;
28
+ }
29
+ next();
30
+ });
31
+ const CLIENT_API_KEY = process.env.CLIENT_API_KEY;
32
+ // ============================================================
33
+ // ATLASSIAN OAUTH 2.0 CONFIGURATION
34
+ // ============================================================
35
+ const ATLASSIAN_CLIENT_ID = process.env.ATLASSIAN_CLIENT_ID;
36
+ const ATLASSIAN_CLIENT_SECRET = process.env.ATLASSIAN_CLIENT_SECRET;
37
+ const OAUTH_CALLBACK_URL = process.env.OAUTH_CALLBACK_URL || "https://atlassian-mcp-793556042663.asia-southeast1.run.app/oauth/callback";
38
+ const oauthTokens = new Map();
39
+ const pendingOAuthStates = new Map();
40
+ const authorizationCodes = new Map();
41
+ // Cleanup expired states every 10 minutes
42
+ setInterval(() => {
43
+ const now = Date.now();
44
+ for (const [state, data] of pendingOAuthStates) {
45
+ if (now - data.createdAt > 10 * 60 * 1000) {
46
+ pendingOAuthStates.delete(state);
47
+ }
48
+ }
49
+ }, 10 * 60 * 1000);
50
+ // Timing-safe API key comparison to prevent timing attacks
51
+ function timingSafeEqual(a, b) {
52
+ if (a.length !== b.length) {
53
+ crypto.timingSafeEqual(Buffer.from(a), Buffer.from(a));
54
+ return false;
55
+ }
56
+ return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
57
+ }
58
+ // Authentication middleware - supports both X-Api-Key and OAuth tokens
59
+ const authenticate = (req, res, next) => {
60
+ const apiKey = req.headers["x-api-key"];
61
+ const oauthToken = req.headers["x-oauth-token"] ||
62
+ req.headers.authorization?.replace("Bearer ", "");
63
+ // Check OAuth token first
64
+ if (oauthToken) {
65
+ const oauthData = oauthTokens.get(oauthToken);
66
+ if (oauthData) {
67
+ // Check if token is expired
68
+ if (Date.now() > oauthData.expiresAt) {
69
+ res.status(401).json({ error: "OAuth token expired, please refresh or re-authenticate" });
70
+ return;
71
+ }
72
+ // Attach OAuth data to request for later use
73
+ req.oauth = oauthData;
74
+ next();
75
+ return;
76
+ }
77
+ }
78
+ // Fall back to API key authentication
79
+ if (!CLIENT_API_KEY) {
80
+ // No auth configured, allow all
81
+ next();
82
+ return;
83
+ }
84
+ if (typeof apiKey === "string" && timingSafeEqual(apiKey, CLIENT_API_KEY)) {
85
+ next();
86
+ return;
87
+ }
88
+ // Include WWW-Authenticate header to tell clients where to authenticate (RFC 9728)
89
+ const proto = req.headers["x-forwarded-proto"] || req.protocol || "https";
90
+ const host = req.headers["x-forwarded-host"] || req.headers.host || "localhost:8080";
91
+ const baseUrl = `${proto}://${host}`;
92
+ res.setHeader("WWW-Authenticate", `Bearer realm="mcp", resource_metadata="${baseUrl}/.well-known/oauth-authorization-server"`);
93
+ res.status(401).json({ error: "unauthorized", message: "Bearer token required for MCP access" });
94
+ };
95
+ // Store active transports for session management
96
+ const sessions = new Map();
97
+ const rateLimitMap = new Map();
98
+ const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
99
+ const RATE_LIMIT_MAX_REQUESTS = 100;
100
+ // Cleanup old entries every 5 minutes
101
+ setInterval(() => {
102
+ const now = Date.now();
103
+ for (const [key, entry] of rateLimitMap) {
104
+ if (now > entry.resetAt) {
105
+ rateLimitMap.delete(key);
106
+ }
107
+ }
108
+ }, 5 * 60 * 1000);
109
+ const rateLimit = (req, res, next) => {
110
+ // Use API key or IP as identifier
111
+ const clientId = req.headers["x-api-key"] || req.ip || "unknown";
112
+ const now = Date.now();
113
+ let entry = rateLimitMap.get(clientId);
114
+ if (!entry || now > entry.resetAt) {
115
+ // New window
116
+ entry = { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS };
117
+ rateLimitMap.set(clientId, entry);
118
+ }
119
+ else {
120
+ entry.count++;
121
+ }
122
+ // Set rate limit headers
123
+ res.setHeader("X-RateLimit-Limit", RATE_LIMIT_MAX_REQUESTS);
124
+ res.setHeader("X-RateLimit-Remaining", Math.max(0, RATE_LIMIT_MAX_REQUESTS - entry.count));
125
+ res.setHeader("X-RateLimit-Reset", Math.ceil(entry.resetAt / 1000));
126
+ if (entry.count > RATE_LIMIT_MAX_REQUESTS) {
127
+ res.status(429).json({
128
+ error: "Too Many Requests",
129
+ message: `Rate limit exceeded. Max ${RATE_LIMIT_MAX_REQUESTS} requests per minute.`,
130
+ retryAfter: Math.ceil((entry.resetAt - now) / 1000),
131
+ });
132
+ return;
133
+ }
134
+ next();
135
+ };
136
+ // ============================================================
137
+ // COMMON ZOD SCHEMAS
138
+ // ============================================================
139
+ const issueKeySchema = z
140
+ .string()
141
+ .regex(/^[A-Z][A-Z0-9]*-\d+$/i, "Invalid issue key format (expected: ABC-123)")
142
+ .describe("The Jira issue key (e.g., ABC-123)");
143
+ const projectKeySchema = z
144
+ .string()
145
+ .regex(/^[A-Z][A-Z0-9]*$/i, "Invalid project key format")
146
+ .describe("The Jira project key (e.g., PROJ)");
147
+ const maxResultsSchema = (defaultVal, max) => z.number().min(1).max(max).optional().default(defaultVal);
148
+ // ============================================================
149
+ // ADF (Atlassian Document Format) SCHEMAS
150
+ // ============================================================
151
+ /**
152
+ * Schema for raw ADF nodes (panels, status, emoji, mention, etc.)
153
+ * Using passthrough to allow any valid ADF structure
154
+ * See: https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
155
+ */
156
+ const adfNodeSchema = z.record(z.string(), z.unknown()).describe("ADF node (panel, status, emoji, etc.)");
157
+ /**
158
+ * Schema for a complete ADF document
159
+ */
160
+ const adfDocumentSchema = z.object({
161
+ type: z.literal("doc"),
162
+ version: z.literal(1),
163
+ content: z.array(adfNodeSchema),
164
+ }).describe("Complete ADF document");
165
+ /**
166
+ * Schema for description blocks (hybrid Markdown + ADF)
167
+ * Allows mixing simple Markdown with complex Jira-specific ADF nodes
168
+ */
169
+ const descriptionBlockSchema = z.union([
170
+ z.object({ markdown: z.string().describe("Markdown text (converted to ADF)") }),
171
+ z.object({ adf: adfNodeSchema }),
172
+ ]).describe("A block that can be either Markdown or raw ADF");
173
+ /**
174
+ * Format issue search results based on response_mode
175
+ * - keys_only: Just issue keys (smallest response)
176
+ * - summary: Essential fields only (key, summary, status, assignee, priority, type)
177
+ * - full: All fields (original behavior)
178
+ */
179
+ function formatIssueResponse(data, response_mode = "summary") {
180
+ if (response_mode === "keys_only") {
181
+ const keys = data.issues.map(i => i.key);
182
+ return {
183
+ content: [{ type: "text", text: `Found ${data.total} issues: ${keys.join(", ")}` }],
184
+ };
185
+ }
186
+ const issues = data.issues.map(issue => {
187
+ if (response_mode === "summary") {
188
+ return {
189
+ key: issue.key,
190
+ summary: issue.fields.summary,
191
+ status: issue.fields.status?.name,
192
+ assignee: issue.fields.assignee?.displayName || null,
193
+ priority: issue.fields.priority?.name,
194
+ type: issue.fields.issuetype?.name,
195
+ };
196
+ }
197
+ return issue; // full mode
198
+ });
199
+ return {
200
+ content: [
201
+ { type: "text", text: `Found ${data.total} issues (showing ${data.issues.length})` },
202
+ { type: "text", text: JSON.stringify({ total: data.total, issues }, null, 2) },
203
+ ],
204
+ };
205
+ }
206
+ // ============================================================
207
+ // CREATE MCP SERVER WITH ALL TOOLS
208
+ // ============================================================
209
+ export function createMcpServer() {
210
+ const atlassian = new AtlassianManager();
211
+ const mcpServer = new McpServer({
212
+ name: "atlassian-mcp-cloud",
213
+ version: "2.0.0",
214
+ });
215
+ // ============================================================
216
+ // ISSUE TOOLS - CRUD
217
+ // ============================================================
218
+ mcpServer.registerTool("get_jira_issue", {
219
+ description: "Get details of a Jira issue by key (e.g., PROJ-123)",
220
+ inputSchema: z.object({
221
+ issueKey: issueKeySchema,
222
+ }),
223
+ }, async ({ issueKey }) => {
224
+ const data = await atlassian.getJiraIssue(issueKey);
225
+ return {
226
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
227
+ };
228
+ });
229
+ mcpServer.registerTool("search_jira_issues", {
230
+ description: "Search issues using JQL",
231
+ inputSchema: z.object({
232
+ jql: z.string().min(1).max(5000).describe("JQL query"),
233
+ maxResults: maxResultsSchema(20, 50),
234
+ fields: z.array(z.string()).optional().describe("Specific fields to return"),
235
+ response_mode: z.enum(["full", "summary", "keys_only"]).optional().default("summary").describe("full=all, summary=essential, keys_only=just keys"),
236
+ }),
237
+ }, async ({ jql, maxResults, fields, response_mode }) => {
238
+ // Optimize API request based on response_mode
239
+ const effectiveFields = fields ?? (response_mode === "keys_only" ? ["key"] :
240
+ response_mode === "summary" ? ["key", "summary", "status", "assignee", "priority", "issuetype"] : undefined);
241
+ const data = await atlassian.searchIssues(jql, maxResults, effectiveFields);
242
+ return formatIssueResponse(data, response_mode);
243
+ });
244
+ mcpServer.registerTool("create_jira_issue", {
245
+ description: "Create Jira issue. Description formats: description (markdown), description_adf (raw ADF), description_blocks (mix). For panels/status/emoji use ADF.",
246
+ inputSchema: z.object({
247
+ projectKey: projectKeySchema,
248
+ issueType: z.string().describe("Issue type (e.g., Task, Bug, Story, Epic)"),
249
+ summary: z.string().min(1).max(255).describe("Issue summary/title"),
250
+ description: z.string().optional().describe("Issue description (Markdown)"),
251
+ description_adf: adfDocumentSchema.optional().describe("Raw ADF document (full control)"),
252
+ description_blocks: z.array(descriptionBlockSchema).optional().describe("Mix of Markdown and ADF blocks"),
253
+ assignee: z.string().optional().describe("Assignee account ID"),
254
+ priority: z.string().optional().describe("Priority (Highest, High, Medium, Low, Lowest)"),
255
+ labels: z.array(z.string()).optional().describe("Labels to add"),
256
+ components: z.array(z.string()).optional().describe("Component names"),
257
+ parentKey: z.string().optional().describe("Parent issue key (for subtasks)"),
258
+ }),
259
+ }, async (params) => {
260
+ const data = await atlassian.createIssue(params);
261
+ return {
262
+ content: [
263
+ { type: "text", text: `Created issue: ${data.key}` },
264
+ { type: "text", text: JSON.stringify(data, null, 2) },
265
+ ],
266
+ };
267
+ });
268
+ mcpServer.registerTool("update_jira_issue", {
269
+ description: "Update Jira issue fields. Same description formats as create_jira_issue.",
270
+ inputSchema: z.object({
271
+ issueKey: issueKeySchema,
272
+ summary: z.string().min(1).max(255).optional().describe("New summary"),
273
+ description: z.string().optional().describe("New description (Markdown)"),
274
+ description_adf: adfDocumentSchema.optional().describe("Raw ADF document (full control)"),
275
+ description_blocks: z.array(descriptionBlockSchema).optional().describe("Mix of Markdown and ADF blocks"),
276
+ assignee: z.string().optional().describe("New assignee (empty to unassign)"),
277
+ priority: z.string().optional().describe("New priority"),
278
+ labels: z.array(z.string()).optional().describe("New labels (replaces existing)"),
279
+ components: z.array(z.string()).optional().describe("New components (replaces existing)"),
280
+ }),
281
+ }, async ({ issueKey, ...params }) => {
282
+ await atlassian.updateIssue(issueKey, params);
283
+ return {
284
+ content: [{ type: "text", text: `Issue ${issueKey} updated successfully` }],
285
+ };
286
+ });
287
+ mcpServer.registerTool("delete_jira_issue", {
288
+ description: "Delete a Jira issue (use with caution!)",
289
+ inputSchema: z.object({
290
+ issueKey: issueKeySchema,
291
+ deleteSubtasks: z.boolean().optional().default(false).describe("Also delete subtasks"),
292
+ }),
293
+ }, async ({ issueKey, deleteSubtasks }) => {
294
+ await atlassian.deleteIssue(issueKey, deleteSubtasks);
295
+ return {
296
+ content: [{ type: "text", text: `Issue ${issueKey} deleted successfully` }],
297
+ };
298
+ });
299
+ mcpServer.registerTool("bulk_create_issues", {
300
+ description: "Create multiple Jira issues at once (max 50)",
301
+ inputSchema: z.object({
302
+ issues: z.array(z.object({
303
+ projectKey: projectKeySchema,
304
+ issueType: z.string(),
305
+ summary: z.string().min(1).max(255),
306
+ description: z.string().optional(),
307
+ assignee: z.string().optional(),
308
+ priority: z.string().optional(),
309
+ labels: z.array(z.string()).optional(),
310
+ parentKey: z.string().optional(),
311
+ })).min(1).max(50).describe("Array of issues to create"),
312
+ }),
313
+ }, async ({ issues }) => {
314
+ const data = await atlassian.bulkCreateIssues(issues);
315
+ return {
316
+ content: [
317
+ { type: "text", text: `Created ${data.issues.length} issues, ${data.errors.length} errors` },
318
+ { type: "text", text: JSON.stringify(data, null, 2) },
319
+ ],
320
+ };
321
+ });
322
+ // ============================================================
323
+ // ISSUE TOOLS - TRANSITIONS & ASSIGNMENT
324
+ // ============================================================
325
+ mcpServer.registerTool("get_issue_transitions", {
326
+ description: "Get available status transitions for an issue (To Do → In Progress, etc.)",
327
+ inputSchema: z.object({
328
+ issueKey: issueKeySchema,
329
+ }),
330
+ }, async ({ issueKey }) => {
331
+ const data = await atlassian.getIssueTransitions(issueKey);
332
+ return {
333
+ content: [
334
+ { type: "text", text: `Found ${data.length} available transitions` },
335
+ { type: "text", text: JSON.stringify(data, null, 2) },
336
+ ],
337
+ };
338
+ });
339
+ mcpServer.registerTool("transition_jira_issue", {
340
+ description: "Transition a Jira issue to a new status (e.g., To Do → In Progress). Use get_issue_transitions first to find the transition ID.",
341
+ inputSchema: z.object({
342
+ issueKey: issueKeySchema,
343
+ transitionId: z.string().describe("Transition ID (from get_issue_transitions)"),
344
+ comment: z.string().optional().describe("Optional comment for the transition"),
345
+ }),
346
+ }, async ({ issueKey, transitionId, comment }) => {
347
+ await atlassian.transitionIssue(issueKey, transitionId, comment);
348
+ return {
349
+ content: [{ type: "text", text: `Issue ${issueKey} transitioned successfully` }],
350
+ };
351
+ });
352
+ mcpServer.registerTool("assign_jira_issue", {
353
+ description: "Assign a Jira issue to a user",
354
+ inputSchema: z.object({
355
+ issueKey: issueKeySchema,
356
+ accountId: z.string().nullable().describe("User account ID (null to unassign)"),
357
+ }),
358
+ }, async ({ issueKey, accountId }) => {
359
+ await atlassian.assignIssue(issueKey, accountId);
360
+ const action = accountId ? "assigned" : "unassigned";
361
+ return {
362
+ content: [{ type: "text", text: `Issue ${issueKey} ${action} successfully` }],
363
+ };
364
+ });
365
+ // ============================================================
366
+ // COMMENT TOOLS
367
+ // ============================================================
368
+ mcpServer.registerTool("get_issue_comments", {
369
+ description: "Get comments on a Jira issue",
370
+ inputSchema: z.object({
371
+ issueKey: issueKeySchema,
372
+ maxResults: maxResultsSchema(50, 100).describe("Maximum comments to return"),
373
+ }),
374
+ }, async ({ issueKey, maxResults }) => {
375
+ const data = await atlassian.getIssueComments(issueKey, maxResults);
376
+ return {
377
+ content: [
378
+ { type: "text", text: `Found ${data.total} comments (showing ${data.comments.length})` },
379
+ { type: "text", text: JSON.stringify(data, null, 2) },
380
+ ],
381
+ };
382
+ });
383
+ mcpServer.registerTool("add_issue_comment", {
384
+ description: "Add a comment to a Jira issue",
385
+ inputSchema: z.object({
386
+ issueKey: issueKeySchema,
387
+ body: z.string().min(1).describe("Comment text"),
388
+ }),
389
+ }, async ({ issueKey, body }) => {
390
+ const data = await atlassian.addIssueComment(issueKey, body);
391
+ return {
392
+ content: [
393
+ { type: "text", text: `Comment added to ${issueKey}` },
394
+ { type: "text", text: JSON.stringify(data, null, 2) },
395
+ ],
396
+ };
397
+ });
398
+ mcpServer.registerTool("update_issue_comment", {
399
+ description: "Update an existing comment on a Jira issue",
400
+ inputSchema: z.object({
401
+ issueKey: issueKeySchema,
402
+ commentId: z.string().describe("Comment ID to update"),
403
+ body: z.string().min(1).describe("New comment text"),
404
+ }),
405
+ }, async ({ issueKey, commentId, body }) => {
406
+ const data = await atlassian.updateIssueComment(issueKey, commentId, body);
407
+ return {
408
+ content: [
409
+ { type: "text", text: `Comment ${commentId} updated` },
410
+ { type: "text", text: JSON.stringify(data, null, 2) },
411
+ ],
412
+ };
413
+ });
414
+ mcpServer.registerTool("delete_issue_comment", {
415
+ description: "Delete a comment from a Jira issue",
416
+ inputSchema: z.object({
417
+ issueKey: issueKeySchema,
418
+ commentId: z.string().describe("Comment ID to delete"),
419
+ }),
420
+ }, async ({ issueKey, commentId }) => {
421
+ await atlassian.deleteIssueComment(issueKey, commentId);
422
+ return {
423
+ content: [{ type: "text", text: `Comment ${commentId} deleted from ${issueKey}` }],
424
+ };
425
+ });
426
+ // ============================================================
427
+ // WORKLOG TOOLS
428
+ // ============================================================
429
+ mcpServer.registerTool("get_issue_worklogs", {
430
+ description: "Get time tracking worklogs for a Jira issue",
431
+ inputSchema: z.object({
432
+ issueKey: issueKeySchema,
433
+ maxResults: maxResultsSchema(50, 100),
434
+ }),
435
+ }, async ({ issueKey, maxResults }) => {
436
+ const data = await atlassian.getIssueWorklogs(issueKey, maxResults);
437
+ return {
438
+ content: [
439
+ { type: "text", text: `Found ${data.total} worklogs (showing ${data.worklogs.length})` },
440
+ { type: "text", text: JSON.stringify(data, null, 2) },
441
+ ],
442
+ };
443
+ });
444
+ mcpServer.registerTool("add_worklog", {
445
+ description: "Add a worklog entry (time tracking) to a Jira issue",
446
+ inputSchema: z.object({
447
+ issueKey: issueKeySchema,
448
+ timeSpent: z.string().describe("Time spent (e.g., '2h 30m', '1d', '30m')"),
449
+ comment: z.string().optional().describe("Work description"),
450
+ started: z.string().optional().describe("Start time (ISO 8601 format)"),
451
+ }),
452
+ }, async ({ issueKey, timeSpent, comment, started }) => {
453
+ const data = await atlassian.addWorklog(issueKey, timeSpent, comment, started);
454
+ return {
455
+ content: [
456
+ { type: "text", text: `Worklog added to ${issueKey}: ${timeSpent}` },
457
+ { type: "text", text: JSON.stringify(data, null, 2) },
458
+ ],
459
+ };
460
+ });
461
+ mcpServer.registerTool("update_worklog", {
462
+ description: "Update an existing worklog entry",
463
+ inputSchema: z.object({
464
+ issueKey: issueKeySchema,
465
+ worklogId: z.string().describe("Worklog ID to update"),
466
+ timeSpent: z.string().describe("New time spent"),
467
+ comment: z.string().optional().describe("New work description"),
468
+ }),
469
+ }, async ({ issueKey, worklogId, timeSpent, comment }) => {
470
+ const data = await atlassian.updateWorklog(issueKey, worklogId, timeSpent, comment);
471
+ return {
472
+ content: [
473
+ { type: "text", text: `Worklog ${worklogId} updated` },
474
+ { type: "text", text: JSON.stringify(data, null, 2) },
475
+ ],
476
+ };
477
+ });
478
+ mcpServer.registerTool("delete_worklog", {
479
+ description: "Delete a worklog entry from a Jira issue",
480
+ inputSchema: z.object({
481
+ issueKey: issueKeySchema,
482
+ worklogId: z.string().describe("Worklog ID to delete"),
483
+ }),
484
+ }, async ({ issueKey, worklogId }) => {
485
+ await atlassian.deleteWorklog(issueKey, worklogId);
486
+ return {
487
+ content: [{ type: "text", text: `Worklog ${worklogId} deleted from ${issueKey}` }],
488
+ };
489
+ });
490
+ // ============================================================
491
+ // ATTACHMENT TOOLS
492
+ // ============================================================
493
+ mcpServer.registerTool("delete_attachment", {
494
+ description: "Delete an attachment from Jira",
495
+ inputSchema: z.object({
496
+ attachmentId: z.string().describe("Attachment ID to delete"),
497
+ }),
498
+ }, async ({ attachmentId }) => {
499
+ await atlassian.deleteAttachment(attachmentId);
500
+ return {
501
+ content: [{ type: "text", text: `Attachment ${attachmentId} deleted` }],
502
+ };
503
+ });
504
+ mcpServer.registerTool("get_attachment_content", {
505
+ description: "Get attachment content (base64 encoded)",
506
+ inputSchema: z.object({
507
+ attachmentId: z.string().describe("Attachment ID"),
508
+ }),
509
+ }, async ({ attachmentId }) => {
510
+ const data = await atlassian.getAttachmentContent(attachmentId);
511
+ return {
512
+ content: [
513
+ { type: "text", text: `Attachment: ${data.filename}` },
514
+ { type: "text", text: `Content (base64): ${data.content.substring(0, 100)}...` },
515
+ ],
516
+ };
517
+ });
518
+ // ============================================================
519
+ // ISSUE LINK TOOLS
520
+ // ============================================================
521
+ mcpServer.registerTool("link_issues", {
522
+ description: "Create a link between two Jira issues",
523
+ inputSchema: z.object({
524
+ inwardIssueKey: issueKeySchema.describe("Source issue key"),
525
+ outwardIssueKey: issueKeySchema.describe("Target issue key"),
526
+ linkType: z.string().describe("Link type (e.g., 'Blocks', 'Relates', 'Duplicates')"),
527
+ }),
528
+ }, async ({ inwardIssueKey, outwardIssueKey, linkType }) => {
529
+ await atlassian.linkIssues(inwardIssueKey, outwardIssueKey, linkType);
530
+ return {
531
+ content: [{ type: "text", text: `Linked ${inwardIssueKey} → ${outwardIssueKey} (${linkType})` }],
532
+ };
533
+ });
534
+ mcpServer.registerTool("delete_issue_link", {
535
+ description: "Delete a link between issues",
536
+ inputSchema: z.object({
537
+ linkId: z.string().describe("Issue link ID to delete"),
538
+ }),
539
+ }, async ({ linkId }) => {
540
+ await atlassian.deleteIssueLink(linkId);
541
+ return {
542
+ content: [{ type: "text", text: `Issue link ${linkId} deleted` }],
543
+ };
544
+ });
545
+ mcpServer.registerTool("get_issue_link_types", {
546
+ description: "Get all available issue link types",
547
+ inputSchema: z.object({}),
548
+ }, async () => {
549
+ const data = await atlassian.getIssueLinkTypes();
550
+ return {
551
+ content: [
552
+ { type: "text", text: `Found ${data.length} link types` },
553
+ { type: "text", text: JSON.stringify(data, null, 2) },
554
+ ],
555
+ };
556
+ });
557
+ // ============================================================
558
+ // WATCHER TOOLS
559
+ // ============================================================
560
+ mcpServer.registerTool("get_issue_watchers", {
561
+ description: "Get watchers of a Jira issue",
562
+ inputSchema: z.object({
563
+ issueKey: issueKeySchema,
564
+ }),
565
+ }, async ({ issueKey }) => {
566
+ const data = await atlassian.getIssueWatchers(issueKey);
567
+ return {
568
+ content: [
569
+ { type: "text", text: `Found ${data.watchers.length} watchers` },
570
+ { type: "text", text: JSON.stringify(data, null, 2) },
571
+ ],
572
+ };
573
+ });
574
+ mcpServer.registerTool("add_watcher", {
575
+ description: "Add a watcher to a Jira issue",
576
+ inputSchema: z.object({
577
+ issueKey: issueKeySchema,
578
+ accountId: z.string().describe("User account ID to add as watcher"),
579
+ }),
580
+ }, async ({ issueKey, accountId }) => {
581
+ await atlassian.addWatcher(issueKey, accountId);
582
+ return {
583
+ content: [{ type: "text", text: `Watcher added to ${issueKey}` }],
584
+ };
585
+ });
586
+ mcpServer.registerTool("remove_watcher", {
587
+ description: "Remove a watcher from a Jira issue",
588
+ inputSchema: z.object({
589
+ issueKey: issueKeySchema,
590
+ accountId: z.string().describe("User account ID to remove"),
591
+ }),
592
+ }, async ({ issueKey, accountId }) => {
593
+ await atlassian.removeWatcher(issueKey, accountId);
594
+ return {
595
+ content: [{ type: "text", text: `Watcher removed from ${issueKey}` }],
596
+ };
597
+ });
598
+ // ============================================================
599
+ // PROJECT TOOLS
600
+ // ============================================================
601
+ mcpServer.registerTool("list_projects", {
602
+ description: "List Jira projects",
603
+ inputSchema: z.object({
604
+ maxResults: maxResultsSchema(50, 100),
605
+ response_mode: z.enum(["full", "summary", "keys_only"]).optional().default("summary").describe("full=all fields, summary=key+name, keys_only=just keys"),
606
+ }),
607
+ }, async ({ maxResults, response_mode }) => {
608
+ const data = await atlassian.listProjects(maxResults);
609
+ if (response_mode === "keys_only") {
610
+ const keys = data.map((p) => p.key);
611
+ return {
612
+ content: [{ type: "text", text: `Projects: ${keys.join(", ")}` }],
613
+ };
614
+ }
615
+ if (response_mode === "summary") {
616
+ const projects = data.map((p) => ({
617
+ key: p.key,
618
+ name: p.name,
619
+ type: p.projectTypeKey,
620
+ }));
621
+ return {
622
+ content: [{ type: "text", text: JSON.stringify({ count: projects.length, projects }, null, 2) }],
623
+ };
624
+ }
625
+ // full mode
626
+ return {
627
+ content: [{ type: "text", text: JSON.stringify({ count: data.length, projects: data }, null, 2) }],
628
+ };
629
+ });
630
+ mcpServer.registerTool("get_project", {
631
+ description: "Get details of a Jira project",
632
+ inputSchema: z.object({
633
+ projectKey: projectKeySchema,
634
+ }),
635
+ }, async ({ projectKey }) => {
636
+ const data = await atlassian.getProject(projectKey);
637
+ return {
638
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
639
+ };
640
+ });
641
+ mcpServer.registerTool("get_project_components", {
642
+ description: "Get components of a Jira project",
643
+ inputSchema: z.object({
644
+ projectKey: projectKeySchema,
645
+ }),
646
+ }, async ({ projectKey }) => {
647
+ const data = await atlassian.getProjectComponents(projectKey);
648
+ return {
649
+ content: [
650
+ { type: "text", text: `Found ${data.length} components` },
651
+ { type: "text", text: JSON.stringify(data, null, 2) },
652
+ ],
653
+ };
654
+ });
655
+ mcpServer.registerTool("get_project_versions", {
656
+ description: "Get versions/releases of a Jira project",
657
+ inputSchema: z.object({
658
+ projectKey: projectKeySchema,
659
+ }),
660
+ }, async ({ projectKey }) => {
661
+ const data = await atlassian.getProjectVersions(projectKey);
662
+ return {
663
+ content: [
664
+ { type: "text", text: `Found ${data.length} versions` },
665
+ { type: "text", text: JSON.stringify(data, null, 2) },
666
+ ],
667
+ };
668
+ });
669
+ mcpServer.registerTool("get_project_statuses", {
670
+ description: "Get available statuses in a Jira project",
671
+ inputSchema: z.object({
672
+ projectKey: projectKeySchema,
673
+ }),
674
+ }, async ({ projectKey }) => {
675
+ const data = await atlassian.getProjectStatuses(projectKey);
676
+ return {
677
+ content: [
678
+ { type: "text", text: `Found ${data.length} statuses` },
679
+ { type: "text", text: JSON.stringify(data, null, 2) },
680
+ ],
681
+ };
682
+ });
683
+ mcpServer.registerTool("create_project_version", {
684
+ description: "Create a new version/release in a Jira project",
685
+ inputSchema: z.object({
686
+ projectKey: projectKeySchema,
687
+ name: z.string().describe("Version name (e.g., '1.0.0')"),
688
+ description: z.string().optional().describe("Version description"),
689
+ releaseDate: z.string().optional().describe("Release date (YYYY-MM-DD)"),
690
+ startDate: z.string().optional().describe("Start date (YYYY-MM-DD)"),
691
+ }),
692
+ }, async ({ projectKey, name, description, releaseDate, startDate }) => {
693
+ const data = await atlassian.createProjectVersion(projectKey, name, description, releaseDate, startDate);
694
+ return {
695
+ content: [
696
+ { type: "text", text: `Version '${name}' created in ${projectKey}` },
697
+ { type: "text", text: JSON.stringify(data, null, 2) },
698
+ ],
699
+ };
700
+ });
701
+ mcpServer.registerTool("create_project_component", {
702
+ description: "Create a new component in a Jira project",
703
+ inputSchema: z.object({
704
+ projectKey: projectKeySchema,
705
+ name: z.string().describe("Component name"),
706
+ description: z.string().optional().describe("Component description"),
707
+ leadAccountId: z.string().optional().describe("Component lead account ID"),
708
+ }),
709
+ }, async ({ projectKey, name, description, leadAccountId }) => {
710
+ const data = await atlassian.createProjectComponent(projectKey, name, description, leadAccountId);
711
+ return {
712
+ content: [
713
+ { type: "text", text: `Component '${name}' created in ${projectKey}` },
714
+ { type: "text", text: JSON.stringify(data, null, 2) },
715
+ ],
716
+ };
717
+ });
718
+ // ============================================================
719
+ // USER TOOLS
720
+ // ============================================================
721
+ mcpServer.registerTool("search_users", {
722
+ description: "Search for Jira users by name or email",
723
+ inputSchema: z.object({
724
+ query: z.string().min(1).describe("Search query"),
725
+ maxResults: maxResultsSchema(20, 50),
726
+ }),
727
+ }, async ({ query, maxResults }) => {
728
+ const data = await atlassian.searchUsers(query, maxResults);
729
+ return {
730
+ content: [
731
+ { type: "text", text: `Found ${data.length} users` },
732
+ { type: "text", text: JSON.stringify(data, null, 2) },
733
+ ],
734
+ };
735
+ });
736
+ mcpServer.registerTool("get_current_user", {
737
+ description: "Get information about the currently authenticated user",
738
+ inputSchema: z.object({}),
739
+ }, async () => {
740
+ const data = await atlassian.getCurrentUser();
741
+ return {
742
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
743
+ };
744
+ });
745
+ mcpServer.registerTool("get_user", {
746
+ description: "Get information about a specific user",
747
+ inputSchema: z.object({
748
+ accountId: z.string().describe("User account ID"),
749
+ }),
750
+ }, async ({ accountId }) => {
751
+ const data = await atlassian.getUser(accountId);
752
+ return {
753
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
754
+ };
755
+ });
756
+ mcpServer.registerTool("get_assignable_users", {
757
+ description: "Get users that can be assigned to issues in a project",
758
+ inputSchema: z.object({
759
+ projectKey: projectKeySchema,
760
+ issueKey: issueKeySchema.optional().describe("Specific issue to check assignability"),
761
+ maxResults: maxResultsSchema(50, 100),
762
+ }),
763
+ }, async ({ projectKey, issueKey, maxResults }) => {
764
+ const data = await atlassian.getAssignableUsers(projectKey, issueKey, maxResults);
765
+ return {
766
+ content: [
767
+ { type: "text", text: `Found ${data.length} assignable users` },
768
+ { type: "text", text: JSON.stringify(data, null, 2) },
769
+ ],
770
+ };
771
+ });
772
+ // ============================================================
773
+ // AGILE - BOARD TOOLS
774
+ // ============================================================
775
+ mcpServer.registerTool("list_boards", {
776
+ description: "List Jira agile boards (Scrum/Kanban)",
777
+ inputSchema: z.object({
778
+ projectKeyOrId: z.string().optional().describe("Filter by project"),
779
+ type: z.enum(["scrum", "kanban"]).optional().describe("Board type filter"),
780
+ maxResults: maxResultsSchema(50, 100),
781
+ }),
782
+ }, async ({ projectKeyOrId, type, maxResults }) => {
783
+ const data = await atlassian.listBoards(projectKeyOrId, type, maxResults);
784
+ return {
785
+ content: [
786
+ { type: "text", text: `Found ${data.length} boards` },
787
+ { type: "text", text: JSON.stringify(data, null, 2) },
788
+ ],
789
+ };
790
+ });
791
+ mcpServer.registerTool("get_board", {
792
+ description: "Get details of a Jira agile board",
793
+ inputSchema: z.object({
794
+ boardId: z.number().describe("Board ID"),
795
+ }),
796
+ }, async ({ boardId }) => {
797
+ const data = await atlassian.getBoard(boardId);
798
+ return {
799
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
800
+ };
801
+ });
802
+ mcpServer.registerTool("get_board_issues", {
803
+ description: "Get issues on a board",
804
+ inputSchema: z.object({
805
+ boardId: z.number().describe("Board ID"),
806
+ jql: z.string().optional().describe("Additional JQL filter"),
807
+ maxResults: maxResultsSchema(50, 100),
808
+ response_mode: z.enum(["full", "summary", "keys_only"]).optional().default("summary"),
809
+ }),
810
+ }, async ({ boardId, jql, maxResults, response_mode }) => {
811
+ const data = await atlassian.getBoardIssues(boardId, jql, maxResults);
812
+ return formatIssueResponse(data, response_mode);
813
+ });
814
+ mcpServer.registerTool("get_backlog_issues", {
815
+ description: "Get backlog issues",
816
+ inputSchema: z.object({
817
+ boardId: z.number().describe("Board ID"),
818
+ maxResults: maxResultsSchema(50, 100),
819
+ response_mode: z.enum(["full", "summary", "keys_only"]).optional().default("summary"),
820
+ }),
821
+ }, async ({ boardId, maxResults, response_mode }) => {
822
+ const data = await atlassian.getBacklogIssues(boardId, maxResults);
823
+ return formatIssueResponse(data, response_mode);
824
+ });
825
+ // ============================================================
826
+ // AGILE - SPRINT TOOLS
827
+ // ============================================================
828
+ mcpServer.registerTool("list_sprints", {
829
+ description: "List sprints for a Jira board",
830
+ inputSchema: z.object({
831
+ boardId: z.number().describe("Board ID"),
832
+ state: z.enum(["active", "future", "closed"]).optional().describe("Sprint state filter"),
833
+ maxResults: maxResultsSchema(50, 100),
834
+ }),
835
+ }, async ({ boardId, state, maxResults }) => {
836
+ const data = await atlassian.listSprints(boardId, state, maxResults);
837
+ return {
838
+ content: [
839
+ { type: "text", text: `Found ${data.length} sprints` },
840
+ { type: "text", text: JSON.stringify(data, null, 2) },
841
+ ],
842
+ };
843
+ });
844
+ mcpServer.registerTool("get_sprint", {
845
+ description: "Get details of a Jira sprint",
846
+ inputSchema: z.object({
847
+ sprintId: z.number().describe("Sprint ID"),
848
+ }),
849
+ }, async ({ sprintId }) => {
850
+ const data = await atlassian.getSprint(sprintId);
851
+ return {
852
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
853
+ };
854
+ });
855
+ mcpServer.registerTool("get_sprint_issues", {
856
+ description: "Get sprint issues",
857
+ inputSchema: z.object({
858
+ sprintId: z.number().describe("Sprint ID"),
859
+ maxResults: maxResultsSchema(50, 100),
860
+ response_mode: z.enum(["full", "summary", "keys_only"]).optional().default("summary"),
861
+ }),
862
+ }, async ({ sprintId, maxResults, response_mode }) => {
863
+ const data = await atlassian.getSprintIssues(sprintId, maxResults);
864
+ return formatIssueResponse(data, response_mode);
865
+ });
866
+ mcpServer.registerTool("create_sprint", {
867
+ description: "Create a new sprint for a Jira board",
868
+ inputSchema: z.object({
869
+ boardId: z.number().describe("Board ID"),
870
+ name: z.string().describe("Sprint name"),
871
+ goal: z.string().optional().describe("Sprint goal"),
872
+ startDate: z.string().optional().describe("Start date (ISO 8601)"),
873
+ endDate: z.string().optional().describe("End date (ISO 8601)"),
874
+ }),
875
+ }, async ({ boardId, name, goal, startDate, endDate }) => {
876
+ const data = await atlassian.createSprint(boardId, name, goal, startDate, endDate);
877
+ return {
878
+ content: [
879
+ { type: "text", text: `Sprint '${name}' created` },
880
+ { type: "text", text: JSON.stringify(data, null, 2) },
881
+ ],
882
+ };
883
+ });
884
+ mcpServer.registerTool("update_sprint", {
885
+ description: "Update a Jira sprint (name, goal, dates, state)",
886
+ inputSchema: z.object({
887
+ sprintId: z.number().describe("Sprint ID"),
888
+ name: z.string().optional().describe("New sprint name"),
889
+ goal: z.string().optional().describe("New sprint goal"),
890
+ state: z.enum(["active", "future", "closed"]).optional().describe("New sprint state"),
891
+ startDate: z.string().optional().describe("New start date"),
892
+ endDate: z.string().optional().describe("New end date"),
893
+ }),
894
+ }, async ({ sprintId, ...updates }) => {
895
+ const data = await atlassian.updateSprint(sprintId, updates);
896
+ return {
897
+ content: [
898
+ { type: "text", text: `Sprint ${sprintId} updated` },
899
+ { type: "text", text: JSON.stringify(data, null, 2) },
900
+ ],
901
+ };
902
+ });
903
+ mcpServer.registerTool("move_issues_to_sprint", {
904
+ description: "Move issues to a sprint",
905
+ inputSchema: z.object({
906
+ sprintId: z.number().describe("Target sprint ID"),
907
+ issueKeys: z.array(issueKeySchema).min(1).describe("Issue keys to move"),
908
+ }),
909
+ }, async ({ sprintId, issueKeys }) => {
910
+ await atlassian.moveIssuesToSprint(sprintId, issueKeys);
911
+ return {
912
+ content: [{ type: "text", text: `Moved ${issueKeys.length} issues to sprint ${sprintId}` }],
913
+ };
914
+ });
915
+ // ============================================================
916
+ // AGILE - EPIC TOOLS
917
+ // ============================================================
918
+ mcpServer.registerTool("get_epic", {
919
+ description: "Get details of a Jira epic",
920
+ inputSchema: z.object({
921
+ epicIdOrKey: z.string().describe("Epic ID or issue key"),
922
+ }),
923
+ }, async ({ epicIdOrKey }) => {
924
+ const data = await atlassian.getEpic(epicIdOrKey);
925
+ return {
926
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
927
+ };
928
+ });
929
+ mcpServer.registerTool("get_epic_issues", {
930
+ description: "Get issues belonging to an epic",
931
+ inputSchema: z.object({
932
+ epicIdOrKey: z.string().describe("Epic ID or issue key"),
933
+ maxResults: maxResultsSchema(50, 100),
934
+ response_mode: z.enum(["full", "summary", "keys_only"]).optional().default("summary"),
935
+ }),
936
+ }, async ({ epicIdOrKey, maxResults, response_mode }) => {
937
+ const data = await atlassian.getEpicIssues(epicIdOrKey, maxResults);
938
+ return formatIssueResponse(data, response_mode);
939
+ });
940
+ mcpServer.registerTool("move_issues_to_epic", {
941
+ description: "Move issues to an epic",
942
+ inputSchema: z.object({
943
+ epicIdOrKey: z.string().describe("Target epic ID or key"),
944
+ issueKeys: z.array(issueKeySchema).min(1).describe("Issue keys to move"),
945
+ }),
946
+ }, async ({ epicIdOrKey, issueKeys }) => {
947
+ await atlassian.moveIssuesToEpic(epicIdOrKey, issueKeys);
948
+ return {
949
+ content: [{ type: "text", text: `Moved ${issueKeys.length} issues to epic ${epicIdOrKey}` }],
950
+ };
951
+ });
952
+ mcpServer.registerTool("remove_issues_from_epic", {
953
+ description: "Remove issues from their epic",
954
+ inputSchema: z.object({
955
+ issueKeys: z.array(issueKeySchema).min(1).describe("Issue keys to remove from epic"),
956
+ }),
957
+ }, async ({ issueKeys }) => {
958
+ await atlassian.removeIssuesFromEpic(issueKeys);
959
+ return {
960
+ content: [{ type: "text", text: `Removed ${issueKeys.length} issues from their epics` }],
961
+ };
962
+ });
963
+ // ============================================================
964
+ // FILTER TOOLS
965
+ // ============================================================
966
+ mcpServer.registerTool("get_my_filters", {
967
+ description: "Get your saved Jira filters",
968
+ inputSchema: z.object({}),
969
+ }, async () => {
970
+ const data = await atlassian.getMyFilters();
971
+ return {
972
+ content: [
973
+ { type: "text", text: `Found ${data.length} filters` },
974
+ { type: "text", text: JSON.stringify(data, null, 2) },
975
+ ],
976
+ };
977
+ });
978
+ mcpServer.registerTool("get_favorite_filters", {
979
+ description: "Get your favorite Jira filters",
980
+ inputSchema: z.object({}),
981
+ }, async () => {
982
+ const data = await atlassian.getFavoriteFilters();
983
+ return {
984
+ content: [
985
+ { type: "text", text: `Found ${data.length} favorite filters` },
986
+ { type: "text", text: JSON.stringify(data, null, 2) },
987
+ ],
988
+ };
989
+ });
990
+ mcpServer.registerTool("get_filter", {
991
+ description: "Get details of a Jira filter",
992
+ inputSchema: z.object({
993
+ filterId: z.string().describe("Filter ID"),
994
+ }),
995
+ }, async ({ filterId }) => {
996
+ const data = await atlassian.getFilter(filterId);
997
+ return {
998
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
999
+ };
1000
+ });
1001
+ mcpServer.registerTool("create_filter", {
1002
+ description: "Create a new Jira filter (saved search)",
1003
+ inputSchema: z.object({
1004
+ name: z.string().describe("Filter name"),
1005
+ jql: z.string().describe("JQL query"),
1006
+ description: z.string().optional().describe("Filter description"),
1007
+ favourite: z.boolean().optional().describe("Add to favorites"),
1008
+ }),
1009
+ }, async ({ name, jql, description, favourite }) => {
1010
+ const data = await atlassian.createFilter(name, jql, description, favourite);
1011
+ return {
1012
+ content: [
1013
+ { type: "text", text: `Filter '${name}' created` },
1014
+ { type: "text", text: JSON.stringify(data, null, 2) },
1015
+ ],
1016
+ };
1017
+ });
1018
+ // ============================================================
1019
+ // METADATA TOOLS
1020
+ // ============================================================
1021
+ mcpServer.registerTool("get_fields", {
1022
+ description: "Get all Jira fields (standard and custom)",
1023
+ inputSchema: z.object({}),
1024
+ }, async () => {
1025
+ const data = await atlassian.getFields();
1026
+ return {
1027
+ content: [
1028
+ { type: "text", text: `Found ${data.length} fields` },
1029
+ { type: "text", text: JSON.stringify(data, null, 2) },
1030
+ ],
1031
+ };
1032
+ });
1033
+ mcpServer.registerTool("get_issue_types", {
1034
+ description: "Get all Jira issue types",
1035
+ inputSchema: z.object({}),
1036
+ }, async () => {
1037
+ const data = await atlassian.getIssueTypes();
1038
+ return {
1039
+ content: [
1040
+ { type: "text", text: `Found ${data.length} issue types` },
1041
+ { type: "text", text: JSON.stringify(data, null, 2) },
1042
+ ],
1043
+ };
1044
+ });
1045
+ mcpServer.registerTool("get_priorities", {
1046
+ description: "Get all Jira priorities",
1047
+ inputSchema: z.object({}),
1048
+ }, async () => {
1049
+ const data = await atlassian.getPriorities();
1050
+ return {
1051
+ content: [
1052
+ { type: "text", text: `Found ${data.length} priorities` },
1053
+ { type: "text", text: JSON.stringify(data, null, 2) },
1054
+ ],
1055
+ };
1056
+ });
1057
+ mcpServer.registerTool("get_statuses", {
1058
+ description: "Get all Jira statuses",
1059
+ inputSchema: z.object({}),
1060
+ }, async () => {
1061
+ const data = await atlassian.getStatuses();
1062
+ return {
1063
+ content: [
1064
+ { type: "text", text: `Found ${data.length} statuses` },
1065
+ { type: "text", text: JSON.stringify(data, null, 2) },
1066
+ ],
1067
+ };
1068
+ });
1069
+ mcpServer.registerTool("get_resolutions", {
1070
+ description: "Get all Jira resolutions",
1071
+ inputSchema: z.object({}),
1072
+ }, async () => {
1073
+ const data = await atlassian.getResolutions();
1074
+ return {
1075
+ content: [
1076
+ { type: "text", text: `Found ${data.length} resolutions` },
1077
+ { type: "text", text: JSON.stringify(data, null, 2) },
1078
+ ],
1079
+ };
1080
+ });
1081
+ mcpServer.registerTool("get_labels", {
1082
+ description: "Get Jira labels",
1083
+ inputSchema: z.object({
1084
+ maxResults: z.number().min(1).max(1000).optional().default(100).describe("Max labels to return (default 100)"),
1085
+ }),
1086
+ }, async ({ maxResults }) => {
1087
+ const data = await atlassian.getLabels(maxResults);
1088
+ return {
1089
+ content: [{ type: "text", text: JSON.stringify({ count: data.length, labels: data }, null, 2) }],
1090
+ };
1091
+ });
1092
+ mcpServer.registerTool("get_server_info", {
1093
+ description: "Get Jira server/instance information",
1094
+ inputSchema: z.object({}),
1095
+ }, async () => {
1096
+ const data = await atlassian.getServerInfo();
1097
+ return {
1098
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
1099
+ };
1100
+ });
1101
+ // ============================================================
1102
+ // CACHE MANAGEMENT TOOLS
1103
+ // ============================================================
1104
+ mcpServer.registerTool("get_cache_stats", {
1105
+ description: "Get metadata cache statistics (cached: fields, issue types, priorities, statuses, resolutions)",
1106
+ inputSchema: z.object({}),
1107
+ }, async () => {
1108
+ const stats = atlassian.getMetadataCacheStats();
1109
+ return {
1110
+ content: [{ type: "text", text: JSON.stringify({ cache: stats, ttl: "30 minutes" }, null, 2) }],
1111
+ };
1112
+ });
1113
+ mcpServer.registerTool("clear_metadata_cache", {
1114
+ description: "Clear the metadata cache to force fresh data from Jira (use if you just added new fields/priorities/etc)",
1115
+ inputSchema: z.object({}),
1116
+ }, async () => {
1117
+ const result = atlassian.clearMetadataCache();
1118
+ return {
1119
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1120
+ };
1121
+ });
1122
+ // ============================================================
1123
+ // CHANGELOG TOOLS (Cloud only)
1124
+ // ============================================================
1125
+ mcpServer.registerTool("get_issue_changelog", {
1126
+ description: "Get issue change history (Cloud only)",
1127
+ inputSchema: z.object({
1128
+ issueKey: issueKeySchema.describe("The Jira issue key (e.g., PROJ-123)"),
1129
+ maxResults: maxResultsSchema(100, 100).describe("Maximum changelog entries to return"),
1130
+ }),
1131
+ }, async (params) => {
1132
+ const data = await atlassian.getIssueChangelog(params.issueKey, params.maxResults);
1133
+ return {
1134
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
1135
+ };
1136
+ });
1137
+ mcpServer.registerTool("batch_get_changelogs", {
1138
+ description: "Get changelogs for multiple issues (Cloud only, max 50)",
1139
+ inputSchema: z.object({
1140
+ issueKeys: z
1141
+ .array(issueKeySchema)
1142
+ .min(1)
1143
+ .max(50)
1144
+ .describe("Array of issue keys (max 50)"),
1145
+ maxResults: maxResultsSchema(50, 100).describe("Maximum changelog entries per issue"),
1146
+ }),
1147
+ }, async (params) => {
1148
+ const data = await atlassian.batchGetChangelogs(params.issueKeys, params.maxResults);
1149
+ return {
1150
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
1151
+ };
1152
+ });
1153
+ mcpServer.registerTool("batch_create_versions", {
1154
+ description: "Create multiple versions in a Jira project at once. Useful for setting up release schedules.",
1155
+ inputSchema: z.object({
1156
+ projectKey: projectKeySchema.describe("The project key"),
1157
+ versions: z
1158
+ .array(z.object({
1159
+ name: z.string().describe("Version name (e.g., 'v1.0.0')"),
1160
+ description: z.string().optional().describe("Version description"),
1161
+ releaseDate: z.string().optional().describe("Release date (YYYY-MM-DD)"),
1162
+ startDate: z.string().optional().describe("Start date (YYYY-MM-DD)"),
1163
+ }))
1164
+ .min(1)
1165
+ .max(50)
1166
+ .describe("Array of versions to create"),
1167
+ }),
1168
+ }, async (params) => {
1169
+ const data = await atlassian.batchCreateVersions(params.projectKey, params.versions);
1170
+ return {
1171
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
1172
+ };
1173
+ });
1174
+ // ============================================================
1175
+ // SEARCH FIELDS (fuzzy search) - v2.6
1176
+ // ============================================================
1177
+ mcpServer.registerTool("search_fields", {
1178
+ description: "Search Jira fields by keyword (fuzzy match on name/id). Useful for finding custom field IDs.",
1179
+ inputSchema: z.object({
1180
+ keyword: z.string().min(1).describe("Search keyword (e.g., 'Tech', 'Sprint', 'Story')"),
1181
+ limit: z.number().min(1).max(50).optional().default(10).describe("Max results (default 10)"),
1182
+ }),
1183
+ }, async ({ keyword, limit }) => {
1184
+ const data = await atlassian.searchFields(keyword, limit);
1185
+ return {
1186
+ content: [
1187
+ { type: "text", text: `Found ${data.length} fields matching "${keyword}"` },
1188
+ { type: "text", text: JSON.stringify(data, null, 2) },
1189
+ ],
1190
+ };
1191
+ });
1192
+ // ============================================================
1193
+ // REMOTE ISSUE LINKS - v2.6
1194
+ // ============================================================
1195
+ mcpServer.registerTool("create_remote_issue_link", {
1196
+ description: "Create a web link on an issue (to Confluence, docs, external URLs)",
1197
+ inputSchema: z.object({
1198
+ issueKey: issueKeySchema,
1199
+ url: z.string().url().describe("Target URL (Confluence, docs, etc.)"),
1200
+ title: z.string().min(1).describe("Link title displayed in Jira"),
1201
+ summary: z.string().optional().describe("Optional description"),
1202
+ iconUrl: z.string().url().optional().describe("16x16 icon URL"),
1203
+ relationship: z.string().optional().describe("Relationship type (default: 'relates to')"),
1204
+ }),
1205
+ }, async ({ issueKey, url, title, summary, iconUrl, relationship }) => {
1206
+ const data = await atlassian.createRemoteIssueLink(issueKey, url, title, summary, iconUrl, relationship);
1207
+ return {
1208
+ content: [
1209
+ { type: "text", text: `Remote link created on ${issueKey}` },
1210
+ { type: "text", text: JSON.stringify(data, null, 2) },
1211
+ ],
1212
+ };
1213
+ });
1214
+ // ============================================================
1215
+ // DOWNLOAD ATTACHMENTS - v2.6
1216
+ // ============================================================
1217
+ mcpServer.registerTool("get_issue_attachments", {
1218
+ description: "List all attachments on an issue",
1219
+ inputSchema: z.object({
1220
+ issueKey: issueKeySchema,
1221
+ }),
1222
+ }, async ({ issueKey }) => {
1223
+ const data = await atlassian.getIssueAttachments(issueKey);
1224
+ const summary = data.map(a => ({
1225
+ id: a.id,
1226
+ filename: a.filename,
1227
+ mimeType: a.mimeType,
1228
+ size: a.size,
1229
+ created: a.created,
1230
+ }));
1231
+ return {
1232
+ content: [
1233
+ { type: "text", text: `Found ${data.length} attachments on ${issueKey}` },
1234
+ { type: "text", text: JSON.stringify(summary, null, 2) },
1235
+ ],
1236
+ };
1237
+ });
1238
+ mcpServer.registerTool("download_attachment", {
1239
+ description: "Download an attachment by ID (returns base64 content)",
1240
+ inputSchema: z.object({
1241
+ attachmentId: z.string().describe("Attachment ID (from get_issue_attachments)"),
1242
+ }),
1243
+ }, async ({ attachmentId }) => {
1244
+ const data = await atlassian.downloadAttachment(attachmentId);
1245
+ return {
1246
+ content: [
1247
+ { type: "text", text: `Downloaded: ${data.filename} (${data.mimeType}, ${data.size} bytes)` },
1248
+ { type: "text", text: JSON.stringify({ filename: data.filename, mimeType: data.mimeType, size: data.size, content_base64: data.content }, null, 2) },
1249
+ ],
1250
+ };
1251
+ });
1252
+ // ============================================================
1253
+ // PROJECT ISSUES (convenience) - v2.6
1254
+ // ============================================================
1255
+ mcpServer.registerTool("get_project_issues", {
1256
+ description: "Get all issues in a project (no JQL needed). Returns issues sorted by created date desc.",
1257
+ inputSchema: z.object({
1258
+ projectKey: projectKeySchema,
1259
+ maxResults: maxResultsSchema(50, 100),
1260
+ response_mode: z.enum(["full", "summary", "keys_only"]).optional().default("summary"),
1261
+ }),
1262
+ }, async ({ projectKey, maxResults, response_mode }) => {
1263
+ const effectiveFields = response_mode === "keys_only" ? ["key"] :
1264
+ response_mode === "summary" ? ["key", "summary", "status", "assignee", "priority", "issuetype"] : undefined;
1265
+ const data = await atlassian.getProjectIssues(projectKey, maxResults, effectiveFields);
1266
+ return formatIssueResponse(data, response_mode);
1267
+ });
1268
+ // ============================================================
1269
+ // LINK ISSUE TO EPIC (convenience) - v2.6
1270
+ // ============================================================
1271
+ mcpServer.registerTool("link_issue_to_epic", {
1272
+ description: "Link a single issue to an epic (convenience wrapper)",
1273
+ inputSchema: z.object({
1274
+ issueKey: issueKeySchema.describe("Issue to link"),
1275
+ epicKey: issueKeySchema.describe("Target epic"),
1276
+ }),
1277
+ }, async ({ issueKey, epicKey }) => {
1278
+ await atlassian.linkIssueToEpic(issueKey, epicKey);
1279
+ return {
1280
+ content: [{ type: "text", text: `Linked ${issueKey} to epic ${epicKey}` }],
1281
+ };
1282
+ });
1283
+ return mcpServer;
1284
+ }
1285
+ // ============================================================
1286
+ // EXPRESS SERVER SETUP
1287
+ // ============================================================
1288
+ app.get("/health", (_req, res) => {
1289
+ res.status(200).json({
1290
+ status: "healthy",
1291
+ timestamp: new Date().toISOString(),
1292
+ activeSessions: sessions.size,
1293
+ version: "2.6.0",
1294
+ features: {
1295
+ metadataCaching: true,
1296
+ retryWithBackoff: true,
1297
+ rateLimiting: true,
1298
+ oauthSupport: !!ATLASSIAN_CLIENT_ID,
1299
+ },
1300
+ });
1301
+ });
1302
+ // ============================================================
1303
+ // OAUTH 2.0 DISCOVERY ENDPOINTS (RFC 8414 & RFC 9728)
1304
+ // ============================================================
1305
+ // Get the base URL for the server
1306
+ const getBaseUrl = (req) => {
1307
+ const proto = req.headers["x-forwarded-proto"] || req.protocol || "https";
1308
+ const host = req.headers["x-forwarded-host"] || req.headers.host || "localhost:8080";
1309
+ return `${proto}://${host}`;
1310
+ };
1311
+ // OAuth Authorization Server Metadata (RFC 8414)
1312
+ app.get("/.well-known/oauth-authorization-server", (req, res) => {
1313
+ const baseUrl = getBaseUrl(req);
1314
+ res.json({
1315
+ issuer: baseUrl,
1316
+ authorization_endpoint: `${baseUrl}/oauth/authorize`,
1317
+ token_endpoint: `${baseUrl}/oauth/token`,
1318
+ registration_endpoint: `${baseUrl}/oauth/register`,
1319
+ token_endpoint_auth_methods_supported: ["none", "client_secret_post", "client_secret_basic"],
1320
+ response_types_supported: ["code"],
1321
+ grant_types_supported: ["authorization_code", "refresh_token"],
1322
+ scopes_supported: [
1323
+ "read:jira-work",
1324
+ "write:jira-work",
1325
+ "read:jira-user",
1326
+ "manage:jira-project",
1327
+ "read:me",
1328
+ "read:account",
1329
+ "offline_access"
1330
+ ],
1331
+ code_challenge_methods_supported: ["S256", "plain"],
1332
+ });
1333
+ });
1334
+ // Also serve at /mcp path variant
1335
+ app.get("/.well-known/oauth-authorization-server/mcp", (req, res) => {
1336
+ const baseUrl = getBaseUrl(req);
1337
+ res.json({
1338
+ issuer: baseUrl,
1339
+ authorization_endpoint: `${baseUrl}/oauth/authorize`,
1340
+ token_endpoint: `${baseUrl}/oauth/token`,
1341
+ registration_endpoint: `${baseUrl}/oauth/register`,
1342
+ token_endpoint_auth_methods_supported: ["none", "client_secret_post", "client_secret_basic"],
1343
+ response_types_supported: ["code"],
1344
+ grant_types_supported: ["authorization_code", "refresh_token"],
1345
+ scopes_supported: [
1346
+ "read:jira-work",
1347
+ "write:jira-work",
1348
+ "read:jira-user",
1349
+ "manage:jira-project",
1350
+ "read:me",
1351
+ "read:account",
1352
+ "offline_access"
1353
+ ],
1354
+ code_challenge_methods_supported: ["S256", "plain"],
1355
+ });
1356
+ });
1357
+ // OAuth Protected Resource Metadata (RFC 9728)
1358
+ app.get("/.well-known/oauth-protected-resource", (req, res) => {
1359
+ const baseUrl = getBaseUrl(req);
1360
+ res.json({
1361
+ resource: `${baseUrl}/mcp`,
1362
+ authorization_servers: [baseUrl],
1363
+ scopes_supported: [
1364
+ "read:jira-work",
1365
+ "write:jira-work",
1366
+ "read:jira-user",
1367
+ "manage:jira-project"
1368
+ ],
1369
+ bearer_methods_supported: ["header"],
1370
+ });
1371
+ });
1372
+ // Also serve at /mcp path variant
1373
+ app.get("/.well-known/oauth-protected-resource/mcp", (req, res) => {
1374
+ const baseUrl = getBaseUrl(req);
1375
+ res.json({
1376
+ resource: `${baseUrl}/mcp`,
1377
+ authorization_servers: [baseUrl],
1378
+ scopes_supported: [
1379
+ "read:jira-work",
1380
+ "write:jira-work",
1381
+ "read:jira-user",
1382
+ "manage:jira-project"
1383
+ ],
1384
+ bearer_methods_supported: ["header"],
1385
+ });
1386
+ });
1387
+ // ============================================================
1388
+ // OAUTH 2.0 ENDPOINTS
1389
+ // ============================================================
1390
+ // Dynamic Client Registration (RFC 7591)
1391
+ // Allows clients like Claude Desktop to register dynamically
1392
+ app.post("/oauth/register", express.json(), (req, res) => {
1393
+ const { client_name, redirect_uris } = req.body;
1394
+ // Generate a client_id for this registration
1395
+ const clientId = `mcp-client-${crypto.randomUUID()}`;
1396
+ console.log(`OAuth: Dynamic client registration - ${client_name}, redirects: ${redirect_uris}`);
1397
+ // Return client credentials (no secret for public clients)
1398
+ res.status(201).json({
1399
+ client_id: clientId,
1400
+ client_name: client_name || "MCP Client",
1401
+ redirect_uris: redirect_uris || [],
1402
+ token_endpoint_auth_method: "none",
1403
+ grant_types: ["authorization_code", "refresh_token"],
1404
+ response_types: ["code"],
1405
+ client_secret_expires_at: 0, // 0 means never expires
1406
+ });
1407
+ });
1408
+ // Step 1: Redirect user to Atlassian OAuth
1409
+ // Accepts Claude Desktop's OAuth parameters: redirect_uri, state, code_challenge, code_challenge_method
1410
+ app.get("/oauth/authorize", (req, res) => {
1411
+ if (!ATLASSIAN_CLIENT_ID) {
1412
+ res.status(500).json({ error: "OAuth not configured: ATLASSIAN_CLIENT_ID missing" });
1413
+ return;
1414
+ }
1415
+ // Generate our internal state for CSRF protection with Atlassian
1416
+ const internalState = crypto.randomUUID();
1417
+ // Store Claude's OAuth parameters to use after Atlassian callback
1418
+ const clientRedirectUri = req.query.redirect_uri;
1419
+ const clientState = req.query.state;
1420
+ const codeChallenge = req.query.code_challenge;
1421
+ const codeChallengeMethod = req.query.code_challenge_method;
1422
+ pendingOAuthStates.set(internalState, {
1423
+ createdAt: Date.now(),
1424
+ clientRedirectUri,
1425
+ clientState,
1426
+ codeChallenge,
1427
+ codeChallengeMethod,
1428
+ });
1429
+ console.log(`OAuth: Starting auth flow (internal_state: ${internalState}, client_redirect: ${clientRedirectUri}, client_state: ${clientState})`);
1430
+ const scopes = [
1431
+ "read:jira-work",
1432
+ "write:jira-work",
1433
+ "read:jira-user",
1434
+ "manage:jira-project",
1435
+ "read:me",
1436
+ "read:account",
1437
+ "offline_access"
1438
+ ].join(" ");
1439
+ const authUrl = new URL("https://auth.atlassian.com/authorize");
1440
+ authUrl.searchParams.set("audience", "api.atlassian.com");
1441
+ authUrl.searchParams.set("client_id", ATLASSIAN_CLIENT_ID);
1442
+ authUrl.searchParams.set("scope", scopes);
1443
+ authUrl.searchParams.set("redirect_uri", OAUTH_CALLBACK_URL);
1444
+ authUrl.searchParams.set("state", internalState);
1445
+ authUrl.searchParams.set("response_type", "code");
1446
+ authUrl.searchParams.set("prompt", "consent");
1447
+ console.log(`OAuth: Redirecting to Atlassian`);
1448
+ res.redirect(authUrl.toString());
1449
+ });
1450
+ // Step 2: Handle OAuth callback from Atlassian
1451
+ // After getting Atlassian tokens, generate our authorization code and redirect to Claude
1452
+ app.get("/oauth/callback", async (req, res) => {
1453
+ const { code, state, error, error_description } = req.query;
1454
+ if (error) {
1455
+ console.error(`OAuth error: ${error} - ${error_description}`);
1456
+ res.status(400).json({ error: error, description: error_description });
1457
+ return;
1458
+ }
1459
+ if (!code || !state) {
1460
+ res.status(400).json({ error: "Missing code or state parameter" });
1461
+ return;
1462
+ }
1463
+ const pendingState = pendingOAuthStates.get(state);
1464
+ if (!pendingState) {
1465
+ res.status(400).json({ error: "Invalid or expired state parameter" });
1466
+ return;
1467
+ }
1468
+ pendingOAuthStates.delete(state);
1469
+ if (!ATLASSIAN_CLIENT_ID || !ATLASSIAN_CLIENT_SECRET) {
1470
+ res.status(500).json({ error: "OAuth not configured" });
1471
+ return;
1472
+ }
1473
+ try {
1474
+ // Exchange Atlassian code for tokens
1475
+ const tokenResponse = await fetch("https://auth.atlassian.com/oauth/token", {
1476
+ method: "POST",
1477
+ headers: { "Content-Type": "application/json" },
1478
+ body: JSON.stringify({
1479
+ grant_type: "authorization_code",
1480
+ client_id: ATLASSIAN_CLIENT_ID,
1481
+ client_secret: ATLASSIAN_CLIENT_SECRET,
1482
+ code: code,
1483
+ redirect_uri: OAUTH_CALLBACK_URL,
1484
+ }),
1485
+ });
1486
+ if (!tokenResponse.ok) {
1487
+ const errorData = await tokenResponse.text();
1488
+ console.error("Token exchange failed:", errorData);
1489
+ res.status(400).json({ error: "Token exchange failed", details: errorData });
1490
+ return;
1491
+ }
1492
+ const tokenData = await tokenResponse.json();
1493
+ // Get accessible resources (cloud IDs)
1494
+ const resourcesResponse = await fetch("https://api.atlassian.com/oauth/token/accessible-resources", {
1495
+ headers: { Authorization: `Bearer ${tokenData.access_token}` },
1496
+ });
1497
+ if (!resourcesResponse.ok) {
1498
+ res.status(400).json({ error: "Failed to get accessible resources" });
1499
+ return;
1500
+ }
1501
+ const resources = await resourcesResponse.json();
1502
+ if (resources.length === 0) {
1503
+ res.status(400).json({ error: "No accessible Atlassian sites found" });
1504
+ return;
1505
+ }
1506
+ const firstResource = resources[0];
1507
+ const cloudId = firstResource.id;
1508
+ const siteName = firstResource.name;
1509
+ console.log(`OAuth: Got Atlassian tokens for site ${siteName} (cloudId: ${cloudId})`);
1510
+ // If Claude sent a redirect_uri, we're in OAuth proxy mode
1511
+ // Generate our own authorization code and redirect back to Claude
1512
+ if (pendingState.clientRedirectUri) {
1513
+ const authCode = crypto.randomUUID();
1514
+ // Store the authorization code with the Atlassian tokens
1515
+ authorizationCodes.set(authCode, {
1516
+ createdAt: Date.now(),
1517
+ accessToken: tokenData.access_token,
1518
+ refreshToken: tokenData.refresh_token,
1519
+ expiresIn: tokenData.expires_in,
1520
+ scope: tokenData.scope,
1521
+ cloudId: cloudId,
1522
+ codeChallenge: pendingState.codeChallenge,
1523
+ codeChallengeMethod: pendingState.codeChallengeMethod,
1524
+ });
1525
+ console.log(`OAuth: Generated auth code ${authCode} for client redirect`);
1526
+ // Redirect to Claude's redirect_uri with our code and their original state
1527
+ const redirectUrl = new URL(pendingState.clientRedirectUri);
1528
+ redirectUrl.searchParams.set("code", authCode);
1529
+ if (pendingState.clientState) {
1530
+ redirectUrl.searchParams.set("state", pendingState.clientState);
1531
+ }
1532
+ console.log(`OAuth: Redirecting to client: ${redirectUrl.toString()}`);
1533
+ res.redirect(redirectUrl.toString());
1534
+ return;
1535
+ }
1536
+ // Direct mode (no client redirect): store tokens and return session token
1537
+ const sessionToken = crypto.randomUUID();
1538
+ oauthTokens.set(sessionToken, {
1539
+ accessToken: tokenData.access_token,
1540
+ refreshToken: tokenData.refresh_token,
1541
+ expiresAt: Date.now() + tokenData.expires_in * 1000,
1542
+ cloudId: cloudId,
1543
+ scopes: tokenData.scope.split(" "),
1544
+ });
1545
+ console.log(`OAuth: Token stored for session ${sessionToken} (site: ${siteName})`);
1546
+ res.json({
1547
+ success: true,
1548
+ token: sessionToken,
1549
+ site: siteName,
1550
+ message: "Use this token in the X-OAuth-Token header or Authorization: Bearer header",
1551
+ });
1552
+ }
1553
+ catch (err) {
1554
+ console.error("OAuth callback error:", err);
1555
+ res.status(500).json({ error: "OAuth callback failed", details: String(err) });
1556
+ }
1557
+ });
1558
+ // Step 3: OAuth Token endpoint - Exchange authorization code for access token
1559
+ // This is the standard OAuth2 token endpoint that Claude Desktop calls
1560
+ app.post("/oauth/token", express.urlencoded({ extended: true }), async (req, res) => {
1561
+ const grantType = req.body.grant_type;
1562
+ const authCode = req.body.code;
1563
+ const codeVerifier = req.body.code_verifier;
1564
+ const refreshToken = req.body.refresh_token;
1565
+ console.log(`OAuth Token: grant_type=${grantType}, code=${authCode ? 'present' : 'missing'}`);
1566
+ // Handle authorization_code grant
1567
+ if (grantType === "authorization_code") {
1568
+ if (!authCode) {
1569
+ res.status(400).json({ error: "invalid_request", error_description: "Missing authorization code" });
1570
+ return;
1571
+ }
1572
+ const codeData = authorizationCodes.get(authCode);
1573
+ if (!codeData) {
1574
+ res.status(400).json({ error: "invalid_grant", error_description: "Invalid or expired authorization code" });
1575
+ return;
1576
+ }
1577
+ // Check if code has expired (10 minutes)
1578
+ if (Date.now() - codeData.createdAt > 10 * 60 * 1000) {
1579
+ authorizationCodes.delete(authCode);
1580
+ res.status(400).json({ error: "invalid_grant", error_description: "Authorization code has expired" });
1581
+ return;
1582
+ }
1583
+ // Verify PKCE code_verifier if code_challenge was provided
1584
+ if (codeData.codeChallenge) {
1585
+ if (!codeVerifier) {
1586
+ res.status(400).json({ error: "invalid_request", error_description: "Missing code_verifier" });
1587
+ return;
1588
+ }
1589
+ // Calculate S256 hash of code_verifier and compare with stored code_challenge
1590
+ const hash = crypto.createHash("sha256").update(codeVerifier).digest();
1591
+ const calculatedChallenge = hash.toString("base64url");
1592
+ if (calculatedChallenge !== codeData.codeChallenge) {
1593
+ res.status(400).json({ error: "invalid_grant", error_description: "Invalid code_verifier" });
1594
+ return;
1595
+ }
1596
+ }
1597
+ // Delete the code (single use)
1598
+ authorizationCodes.delete(authCode);
1599
+ // Generate a session token and store the OAuth data
1600
+ const sessionToken = crypto.randomUUID();
1601
+ oauthTokens.set(sessionToken, {
1602
+ accessToken: codeData.accessToken,
1603
+ refreshToken: codeData.refreshToken,
1604
+ expiresAt: Date.now() + codeData.expiresIn * 1000,
1605
+ cloudId: codeData.cloudId,
1606
+ scopes: codeData.scope.split(" "),
1607
+ });
1608
+ console.log(`OAuth Token: Issued access token for code exchange`);
1609
+ // Return standard OAuth2 token response
1610
+ res.json({
1611
+ access_token: sessionToken,
1612
+ token_type: "Bearer",
1613
+ expires_in: codeData.expiresIn,
1614
+ refresh_token: sessionToken, // Use same token for refresh
1615
+ scope: codeData.scope,
1616
+ });
1617
+ return;
1618
+ }
1619
+ // Handle refresh_token grant
1620
+ if (grantType === "refresh_token") {
1621
+ if (!refreshToken) {
1622
+ res.status(400).json({ error: "invalid_request", error_description: "Missing refresh_token" });
1623
+ return;
1624
+ }
1625
+ const oauthData = oauthTokens.get(refreshToken);
1626
+ if (!oauthData) {
1627
+ res.status(400).json({ error: "invalid_grant", error_description: "Invalid refresh token" });
1628
+ return;
1629
+ }
1630
+ if (!ATLASSIAN_CLIENT_ID || !ATLASSIAN_CLIENT_SECRET) {
1631
+ res.status(500).json({ error: "server_error", error_description: "OAuth not configured" });
1632
+ return;
1633
+ }
1634
+ try {
1635
+ const refreshResponse = await fetch("https://auth.atlassian.com/oauth/token", {
1636
+ method: "POST",
1637
+ headers: { "Content-Type": "application/json" },
1638
+ body: JSON.stringify({
1639
+ grant_type: "refresh_token",
1640
+ client_id: ATLASSIAN_CLIENT_ID,
1641
+ client_secret: ATLASSIAN_CLIENT_SECRET,
1642
+ refresh_token: oauthData.refreshToken,
1643
+ }),
1644
+ });
1645
+ if (!refreshResponse.ok) {
1646
+ oauthTokens.delete(refreshToken);
1647
+ res.status(400).json({ error: "invalid_grant", error_description: "Token refresh failed" });
1648
+ return;
1649
+ }
1650
+ const newTokenData = await refreshResponse.json();
1651
+ // Update stored tokens
1652
+ oauthData.accessToken = newTokenData.access_token;
1653
+ oauthData.refreshToken = newTokenData.refresh_token;
1654
+ oauthData.expiresAt = Date.now() + newTokenData.expires_in * 1000;
1655
+ console.log(`OAuth Token: Refreshed access token`);
1656
+ res.json({
1657
+ access_token: refreshToken, // Keep the same session token
1658
+ token_type: "Bearer",
1659
+ expires_in: newTokenData.expires_in,
1660
+ refresh_token: refreshToken,
1661
+ scope: newTokenData.scope,
1662
+ });
1663
+ }
1664
+ catch (err) {
1665
+ console.error("Token refresh error:", err);
1666
+ res.status(500).json({ error: "server_error", error_description: "Token refresh failed" });
1667
+ }
1668
+ return;
1669
+ }
1670
+ res.status(400).json({ error: "unsupported_grant_type", error_description: `Unsupported grant_type: ${grantType}` });
1671
+ });
1672
+ // Cleanup expired authorization codes every 5 minutes
1673
+ setInterval(() => {
1674
+ const now = Date.now();
1675
+ for (const [code, data] of authorizationCodes) {
1676
+ if (now - data.createdAt > 10 * 60 * 1000) {
1677
+ authorizationCodes.delete(code);
1678
+ }
1679
+ }
1680
+ }, 5 * 60 * 1000);
1681
+ // Legacy token refresh endpoint (for backwards compatibility)
1682
+ app.post("/oauth/refresh", async (req, res) => {
1683
+ const token = req.headers["x-oauth-token"] ||
1684
+ req.headers.authorization?.replace("Bearer ", "");
1685
+ if (!token) {
1686
+ res.status(401).json({ error: "No token provided" });
1687
+ return;
1688
+ }
1689
+ const oauthData = oauthTokens.get(token);
1690
+ if (!oauthData) {
1691
+ res.status(401).json({ error: "Invalid token" });
1692
+ return;
1693
+ }
1694
+ if (!ATLASSIAN_CLIENT_ID || !ATLASSIAN_CLIENT_SECRET) {
1695
+ res.status(500).json({ error: "OAuth not configured" });
1696
+ return;
1697
+ }
1698
+ try {
1699
+ const refreshResponse = await fetch("https://auth.atlassian.com/oauth/token", {
1700
+ method: "POST",
1701
+ headers: { "Content-Type": "application/json" },
1702
+ body: JSON.stringify({
1703
+ grant_type: "refresh_token",
1704
+ client_id: ATLASSIAN_CLIENT_ID,
1705
+ client_secret: ATLASSIAN_CLIENT_SECRET,
1706
+ refresh_token: oauthData.refreshToken,
1707
+ }),
1708
+ });
1709
+ if (!refreshResponse.ok) {
1710
+ oauthTokens.delete(token);
1711
+ res.status(401).json({ error: "Token refresh failed, please re-authenticate" });
1712
+ return;
1713
+ }
1714
+ const newTokenData = await refreshResponse.json();
1715
+ oauthData.accessToken = newTokenData.access_token;
1716
+ oauthData.refreshToken = newTokenData.refresh_token;
1717
+ oauthData.expiresAt = Date.now() + newTokenData.expires_in * 1000;
1718
+ res.json({ success: true, expiresIn: newTokenData.expires_in });
1719
+ }
1720
+ catch (err) {
1721
+ console.error("Token refresh error:", err);
1722
+ res.status(500).json({ error: "Token refresh failed" });
1723
+ }
1724
+ });
1725
+ // Get OAuth token info
1726
+ app.get("/oauth/tokeninfo", (req, res) => {
1727
+ const token = req.headers["x-oauth-token"] ||
1728
+ req.headers.authorization?.replace("Bearer ", "");
1729
+ if (!token) {
1730
+ res.status(401).json({ error: "No token provided" });
1731
+ return;
1732
+ }
1733
+ const oauthData = oauthTokens.get(token);
1734
+ if (!oauthData) {
1735
+ res.status(401).json({ error: "Invalid token" });
1736
+ return;
1737
+ }
1738
+ res.json({
1739
+ valid: true,
1740
+ expiresAt: new Date(oauthData.expiresAt).toISOString(),
1741
+ expiresIn: Math.max(0, Math.floor((oauthData.expiresAt - Date.now()) / 1000)),
1742
+ scopes: oauthData.scopes,
1743
+ });
1744
+ });
1745
+ // HEAD request for /mcp - return protocol version (required by Claude Desktop)
1746
+ app.head("/mcp", rateLimit, (req, res) => {
1747
+ res.setHeader("MCP-Protocol-Version", "2025-06-18");
1748
+ res.setHeader("Content-Type", "application/json");
1749
+ // For unauthenticated HEAD requests, still return 401 with WWW-Authenticate
1750
+ const bearerToken = req.headers.authorization?.replace(/^Bearer\s+/i, "");
1751
+ const apiKey = req.headers["x-api-key"];
1752
+ const hasAuth = bearerToken || (apiKey && CLIENT_API_KEY && apiKey === CLIENT_API_KEY);
1753
+ if (!hasAuth && CLIENT_API_KEY) {
1754
+ const proto = req.headers["x-forwarded-proto"] || req.protocol || "https";
1755
+ const host = req.headers["x-forwarded-host"] || req.headers.host || "localhost:8080";
1756
+ const baseUrl = `${proto}://${host}`;
1757
+ res.setHeader("WWW-Authenticate", `Bearer realm="mcp", resource_metadata="${baseUrl}/.well-known/oauth-authorization-server"`);
1758
+ res.status(401).end();
1759
+ return;
1760
+ }
1761
+ res.status(200).end();
1762
+ });
1763
+ app.all("/mcp", rateLimit, authenticate, async (req, res) => {
1764
+ // Set MCP-Protocol-Version header on all responses
1765
+ res.setHeader("MCP-Protocol-Version", "2025-06-18");
1766
+ const sessionId = crypto.randomUUID();
1767
+ const transport = new StreamableHTTPServerTransport({
1768
+ sessionIdGenerator: () => sessionId,
1769
+ });
1770
+ const mcpServer = createMcpServer();
1771
+ sessions.set(sessionId, transport);
1772
+ req.on("close", () => {
1773
+ sessions.delete(sessionId);
1774
+ console.log(`Session ${sessionId} disconnected. Active sessions: ${sessions.size}`);
1775
+ });
1776
+ try {
1777
+ await mcpServer.connect(transport);
1778
+ console.log(`Session ${sessionId} connected. Active sessions: ${sessions.size}`);
1779
+ await transport.handleRequest(req, res);
1780
+ }
1781
+ catch (error) {
1782
+ sessions.delete(sessionId);
1783
+ console.error(`Failed to handle request for session ${sessionId}:`, error);
1784
+ if (!res.headersSent) {
1785
+ res.status(500).json({ error: "Internal server error" });
1786
+ }
1787
+ }
1788
+ });
1789
+ // Only start HTTP server when running as main entry point (not imported by stdio.ts)
1790
+ const isMainModule = import.meta.url === `file://${process.argv[1]}`;
1791
+ if (isMainModule) {
1792
+ const PORT = process.env.PORT ?? 8080;
1793
+ const server = app.listen(PORT, () => {
1794
+ console.log(`MCP Server running on port ${PORT}`);
1795
+ console.log(`Health check: http://localhost:${PORT}/health`);
1796
+ console.log(`MCP endpoint: http://localhost:${PORT}/mcp`);
1797
+ console.log(`\nRegistered 64 Jira tools across categories:`);
1798
+ console.log(` Issues: get, search, create, update, delete, bulk_create`);
1799
+ console.log(` Transitions: get_transitions, transition, assign`);
1800
+ console.log(` Comments: get, add, update, delete`);
1801
+ console.log(` Worklogs: get, add, update, delete`);
1802
+ console.log(` Attachments: delete, get_content`);
1803
+ console.log(` Links: link_issues, delete_link, get_link_types`);
1804
+ console.log(` Watchers: get, add, remove`);
1805
+ console.log(` Projects: list, get, components, versions, statuses, create_version, create_component`);
1806
+ console.log(` Users: search, get_current, get, get_assignable`);
1807
+ console.log(` Boards: list, get, get_issues, get_backlog`);
1808
+ console.log(` Sprints: list, get, get_issues, create, update, move_issues`);
1809
+ console.log(` Epics: get, get_issues, move_issues, remove_issues`);
1810
+ console.log(` Filters: get_my, get_favorites, get, create`);
1811
+ console.log(` Metadata: fields, issue_types, priorities, statuses, resolutions, labels, server_info`);
1812
+ console.log(` Changelog: get_changelog, batch_get_changelogs`);
1813
+ console.log(` Batch: batch_create_versions`);
1814
+ });
1815
+ function shutdown(signal) {
1816
+ console.log(`\n${signal} received. Shutting down gracefully...`);
1817
+ for (const [sessionId, transport] of sessions) {
1818
+ transport.close().catch(console.error);
1819
+ sessions.delete(sessionId);
1820
+ }
1821
+ server.close(() => {
1822
+ console.log("Server closed. Goodbye!");
1823
+ process.exit(0);
1824
+ });
1825
+ setTimeout(() => {
1826
+ console.error("Forced shutdown after timeout");
1827
+ process.exit(1);
1828
+ }, 10000);
1829
+ }
1830
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
1831
+ process.on("SIGINT", () => shutdown("SIGINT"));
1832
+ }
1833
+ //# sourceMappingURL=index.js.map