@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/README.md +254 -0
- package/dist/atlassian.d.ts +356 -0
- package/dist/atlassian.d.ts.map +1 -0
- package/dist/atlassian.js +1384 -0
- package/dist/atlassian.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1833 -0
- package/dist/index.js.map +1 -0
- package/dist/stdio.d.ts +16 -0
- package/dist/stdio.d.ts.map +1 -0
- package/dist/stdio.js +39 -0
- package/dist/stdio.js.map +1 -0
- package/package.json +57 -0
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
|