@aaronsb/jira-cloud-mcp 0.5.11 → 0.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/build/client/field-discovery.js +24 -0
- package/build/client/graph-object-cache.js +127 -0
- package/build/client/graphql-client.js +95 -0
- package/build/client/graphql-hierarchy.js +253 -0
- package/build/client/jira-client.js +22 -0
- package/build/handlers/analysis-handler.js +205 -9
- package/build/handlers/plan-handler.js +351 -0
- package/build/index.js +72 -2
- package/build/schemas/tool-schemas.js +45 -5
- package/build/utils/next-steps.js +51 -1
- package/package.json +1 -1
- package/build/worker.js +0 -200
package/build/index.js
CHANGED
|
@@ -4,11 +4,14 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
4
4
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
5
|
import { CallToolRequestSchema, ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, McpError, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
6
6
|
import { fieldDiscovery } from './client/field-discovery.js';
|
|
7
|
+
import { GraphObjectCache } from './client/graph-object-cache.js';
|
|
8
|
+
import { discoverCloudId, GraphQLClient } from './client/graphql-client.js';
|
|
7
9
|
import { JiraClient } from './client/jira-client.js';
|
|
8
10
|
import { handleAnalysisRequest } from './handlers/analysis-handler.js';
|
|
9
11
|
import { handleBoardRequest } from './handlers/board-handlers.js';
|
|
10
12
|
import { handleFilterRequest } from './handlers/filter-handlers.js';
|
|
11
13
|
import { handleIssueRequest } from './handlers/issue-handlers.js';
|
|
14
|
+
import { handlePlanRequest } from './handlers/plan-handler.js';
|
|
12
15
|
import { handleProjectRequest } from './handlers/project-handlers.js';
|
|
13
16
|
import { createQueueHandler } from './handlers/queue-handler.js';
|
|
14
17
|
import { setupResourceHandlers } from './handlers/resource-handlers.js';
|
|
@@ -32,9 +35,34 @@ if (!JIRA_EMAIL || !JIRA_API_TOKEN || !JIRA_HOST) {
|
|
|
32
35
|
}
|
|
33
36
|
const require = createRequire(import.meta.url);
|
|
34
37
|
const { version } = require('../package.json');
|
|
38
|
+
/** Map manage_jira_issue update args to GraphIssue field patches */
|
|
39
|
+
function extractChangedFields(args) {
|
|
40
|
+
const fields = {};
|
|
41
|
+
if ('dueDate' in args)
|
|
42
|
+
fields.dueDate = args.dueDate;
|
|
43
|
+
if ('summary' in args)
|
|
44
|
+
fields.summary = args.summary;
|
|
45
|
+
if ('assignee' in args)
|
|
46
|
+
fields.assignee = args.assignee;
|
|
47
|
+
if ('storyPoints' in args)
|
|
48
|
+
fields.storyPoints = args.storyPoints;
|
|
49
|
+
// startDate may come via customFields — check both
|
|
50
|
+
if ('startDate' in args)
|
|
51
|
+
fields.startDate = args.startDate;
|
|
52
|
+
const customFields = args.customFields;
|
|
53
|
+
if (customFields) {
|
|
54
|
+
for (const [key, val] of Object.entries(customFields)) {
|
|
55
|
+
if (key.toLowerCase().includes('start'))
|
|
56
|
+
fields.startDate = val;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return fields;
|
|
60
|
+
}
|
|
35
61
|
class JiraServer {
|
|
36
62
|
server;
|
|
37
63
|
jiraClient;
|
|
64
|
+
graphqlClient = null;
|
|
65
|
+
cache = new GraphObjectCache();
|
|
38
66
|
constructor() {
|
|
39
67
|
const serverName = process.env.MCP_SERVER_NAME || 'jira-cloud';
|
|
40
68
|
console.error(`Initializing Jira MCP server: ${serverName}`);
|
|
@@ -55,7 +83,15 @@ class JiraServer {
|
|
|
55
83
|
});
|
|
56
84
|
this.setupHandlers();
|
|
57
85
|
// Start async field discovery (non-blocking)
|
|
58
|
-
fieldDiscovery.startAsync(this.jiraClient.v3Client)
|
|
86
|
+
fieldDiscovery.startAsync(this.jiraClient.v3Client).then(() => {
|
|
87
|
+
const sprintId = fieldDiscovery.getWellKnownFieldId('sprint');
|
|
88
|
+
if (sprintId) {
|
|
89
|
+
this.jiraClient.setSprintFieldId(sprintId);
|
|
90
|
+
console.error(`[jira-cloud] Sprint field: ${sprintId}`);
|
|
91
|
+
}
|
|
92
|
+
}).catch(() => { });
|
|
93
|
+
// CloudId discovery happens in run() before server connects — must complete
|
|
94
|
+
// before ListTools so analyze_jira_plan is registered if available.
|
|
59
95
|
this.server.onerror = (error) => console.error('[MCP Error]', error);
|
|
60
96
|
process.on('SIGINT', async () => {
|
|
61
97
|
await this.server.close();
|
|
@@ -66,6 +102,7 @@ class JiraServer {
|
|
|
66
102
|
// Set up required MCP protocol handlers
|
|
67
103
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
68
104
|
tools: Object.entries(toolSchemas)
|
|
105
|
+
.filter(([key]) => key !== 'analyze_jira_plan' || this.graphqlClient !== null)
|
|
69
106
|
.map(([key, schema]) => ({
|
|
70
107
|
name: key,
|
|
71
108
|
description: schema.description,
|
|
@@ -101,6 +138,7 @@ class JiraServer {
|
|
|
101
138
|
// Set up tool handlers
|
|
102
139
|
this.server.setRequestHandler(CallToolRequestSchema, async (request, _extra) => {
|
|
103
140
|
console.error('Received request:', JSON.stringify(request, null, 2));
|
|
141
|
+
this.cache.tick();
|
|
104
142
|
const { name } = request.params;
|
|
105
143
|
console.error(`Handling tool request: ${name}`);
|
|
106
144
|
try {
|
|
@@ -110,11 +148,14 @@ class JiraServer {
|
|
|
110
148
|
manage_jira_board: handleBoardRequest,
|
|
111
149
|
manage_jira_sprint: handleSprintRequest,
|
|
112
150
|
manage_jira_filter: handleFilterRequest,
|
|
113
|
-
analyze_jira_issues: handleAnalysisRequest,
|
|
151
|
+
analyze_jira_issues: (client, req) => handleAnalysisRequest(client, req, this.graphqlClient, this.cache),
|
|
114
152
|
};
|
|
115
153
|
const handlers = {
|
|
116
154
|
...toolHandlers,
|
|
117
155
|
queue_jira_operations: createQueueHandler(toolHandlers, JIRA_HOST),
|
|
156
|
+
...(this.graphqlClient ? {
|
|
157
|
+
analyze_jira_plan: (_client, req) => handlePlanRequest(this.jiraClient, this.graphqlClient, req, this.cache),
|
|
158
|
+
} : {}),
|
|
118
159
|
};
|
|
119
160
|
const handler = handlers[name];
|
|
120
161
|
if (!handler) {
|
|
@@ -131,6 +172,21 @@ class JiraServer {
|
|
|
131
172
|
response.content[0].text += `\n\n---\n**💡 Efficiency tip:** You've made ${consecutiveIssueCalls} consecutive \`manage_jira_issue\` calls. Consider using \`queue_jira_operations\` to batch multiple issue operations into a single call — it's faster and uses less context.`;
|
|
132
173
|
consecutiveIssueCalls = 0;
|
|
133
174
|
}
|
|
175
|
+
// Surgical cache patching — update cached issues on mutations
|
|
176
|
+
const reqArgs = request.params.arguments;
|
|
177
|
+
const op = reqArgs?.operation;
|
|
178
|
+
if ((op === 'update' || op === 'transition') && this.cache.walks.size > 0) {
|
|
179
|
+
const issueKey = reqArgs?.issueKey;
|
|
180
|
+
if (issueKey) {
|
|
181
|
+
const changedFields = extractChangedFields(reqArgs);
|
|
182
|
+
if (Object.keys(changedFields).length > 0) {
|
|
183
|
+
const patched = this.cache.patch(issueKey, changedFields);
|
|
184
|
+
if (patched) {
|
|
185
|
+
console.error(`[graph-cache] Patched ${issueKey} in cache`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
134
190
|
}
|
|
135
191
|
else {
|
|
136
192
|
consecutiveIssueCalls = 0;
|
|
@@ -178,6 +234,20 @@ class JiraServer {
|
|
|
178
234
|
});
|
|
179
235
|
}
|
|
180
236
|
async run() {
|
|
237
|
+
// Discover cloudId before connecting — must complete before ListTools
|
|
238
|
+
try {
|
|
239
|
+
const cloudId = await discoverCloudId(JIRA_HOST, JIRA_EMAIL, JIRA_API_TOKEN);
|
|
240
|
+
if (cloudId) {
|
|
241
|
+
this.graphqlClient = new GraphQLClient(JIRA_EMAIL, JIRA_API_TOKEN, cloudId);
|
|
242
|
+
console.error(`[jira-cloud] GraphQL client ready (cloudId: ${cloudId.slice(0, 8)}...)`);
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
console.error('[jira-cloud] GraphQL/Plans unavailable — analyze_jira_plan disabled');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
console.error('[jira-cloud] GraphQL discovery failed — analyze_jira_plan disabled');
|
|
250
|
+
}
|
|
181
251
|
const transport = new StdioServerTransport();
|
|
182
252
|
await this.server.connect(transport);
|
|
183
253
|
console.error('Jira MCP server running on stdio');
|
|
@@ -332,24 +332,28 @@ export const toolSchemas = {
|
|
|
332
332
|
properties: {
|
|
333
333
|
jql: {
|
|
334
334
|
type: 'string',
|
|
335
|
-
description: 'JQL query selecting the issues to analyze. Either jql or
|
|
335
|
+
description: 'JQL query selecting the issues to analyze. Either jql, filterId, or dataRef is required (dataRef > filterId > jql precedence). Examples: "project in (AA, GC, LGS)", "sprint in openSprints()", "assignee = currentUser() AND resolution = Unresolved".',
|
|
336
336
|
},
|
|
337
337
|
filterId: {
|
|
338
338
|
type: 'string',
|
|
339
339
|
description: 'ID of a saved Jira filter to use as the query source. The filter\'s JQL is resolved automatically. Use this to run different analyses against a saved query without repeating the JQL. Create filters with manage_jira_filter.',
|
|
340
340
|
},
|
|
341
|
+
dataRef: {
|
|
342
|
+
type: 'string',
|
|
343
|
+
description: 'Root issue key of a cached hierarchy walk. Analyzes cached plan data without re-fetching from Jira. Start a walk with analyze_jira_plan first. Supports all metrics except flow. Takes precedence over jql/filterId.',
|
|
344
|
+
},
|
|
341
345
|
metrics: {
|
|
342
346
|
type: 'array',
|
|
343
347
|
items: {
|
|
344
348
|
type: 'string',
|
|
345
|
-
enum: ['summary', 'points', 'time', 'schedule', 'cycle', 'distribution', 'flow', 'cube_setup'],
|
|
349
|
+
enum: ['summary', 'points', 'time', 'schedule', 'cycle', 'distribution', 'flow', 'hierarchy', 'cube_setup'],
|
|
346
350
|
},
|
|
347
|
-
description: 'Which metric groups to compute. summary = exact counts via count API (no issue cap, fastest) — use with groupBy for "how many by assignee/status/priority" questions. distribution = approximate counts from fetched issues (capped by maxResults — use summary + groupBy instead when you need exact counts). flow = status transition analysis from bulk changelogs — entries per status, avg time in each, bounce rates, top bouncers. cube_setup = discover dimensions before cube queries. points = earned value/SPI. time = effort estimates. schedule = overdue/risk. cycle = lead time/throughput. Default: all detail metrics (excluding flow — request
|
|
351
|
+
description: 'Which metric groups to compute. summary = exact counts via count API (no issue cap, fastest) — use with groupBy for "how many by assignee/status/priority" questions. distribution = approximate counts from fetched issues (capped by maxResults — use summary + groupBy instead when you need exact counts). flow = status transition analysis from bulk changelogs — entries per status, avg time in each, bounce rates, top bouncers. hierarchy = tree visualization with rollups for parent-child structures (requires GraphQL — opt-in like flow). cube_setup = discover dimensions before cube queries. points = earned value/SPI. time = effort estimates. schedule = overdue/risk. cycle = lead time/throughput. Default: all detail metrics (excluding flow and hierarchy — request explicitly). For counting/breakdown questions, always prefer summary + groupBy over distribution.',
|
|
348
352
|
},
|
|
349
353
|
groupBy: {
|
|
350
354
|
type: 'string',
|
|
351
|
-
enum: ['project', 'assignee', 'priority', 'issuetype'],
|
|
352
|
-
description: 'Split counts by this dimension — produces a breakdown table. Use with metrics: ["summary"] for exact counts. This is the correct approach for "how many issues per assignee/priority/type" questions. "project" produces a per-project comparison.',
|
|
355
|
+
enum: ['project', 'assignee', 'priority', 'issuetype', 'parent', 'sprint'],
|
|
356
|
+
description: 'Split counts by this dimension — produces a breakdown table. Use with metrics: ["summary"] for exact counts. This is the correct approach for "how many issues per assignee/priority/type" questions. "project" produces a per-project comparison. "parent" groups by parent issue. "sprint" groups by sprint name.',
|
|
353
357
|
},
|
|
354
358
|
compute: {
|
|
355
359
|
type: 'array',
|
|
@@ -371,6 +375,42 @@ export const toolSchemas = {
|
|
|
371
375
|
required: [],
|
|
372
376
|
},
|
|
373
377
|
},
|
|
378
|
+
analyze_jira_plan: {
|
|
379
|
+
name: 'analyze_jira_plan',
|
|
380
|
+
description: 'Analyze hierarchy rollups for any parent issue. Walks the issue tree via GraphQL, computes rolled-up dates, points, progress, assignees, and detects date conflicts. Results are cached server-side for fast re-analysis. Works on any Jira instance (no Plans/Premium required). For flat-set metrics use analyze_jira_issues (with dataRef to analyze cached plan data); for structure without rollups use manage_jira_issue hierarchy.',
|
|
381
|
+
inputSchema: {
|
|
382
|
+
type: 'object',
|
|
383
|
+
properties: {
|
|
384
|
+
operation: {
|
|
385
|
+
type: 'string',
|
|
386
|
+
enum: ['analyze', 'release'],
|
|
387
|
+
description: 'Operation to perform. analyze (default): walk hierarchy and compute rollups. release: free cached walk data for this issueKey.',
|
|
388
|
+
},
|
|
389
|
+
issueKey: {
|
|
390
|
+
type: 'string',
|
|
391
|
+
description: 'Issue key at the root of the plan tree (e.g., PROJ-100). Required.',
|
|
392
|
+
},
|
|
393
|
+
rollups: {
|
|
394
|
+
type: 'array',
|
|
395
|
+
items: {
|
|
396
|
+
type: 'string',
|
|
397
|
+
enum: ['dates', 'points', 'progress', 'assignees'],
|
|
398
|
+
},
|
|
399
|
+
description: 'Which rollup dimensions to include. Default: all.',
|
|
400
|
+
},
|
|
401
|
+
focus: {
|
|
402
|
+
type: 'string',
|
|
403
|
+
description: 'Issue key to focus on within the cached plan. Shows the node, its parent, siblings, and children — a windowed view for navigating large plans. Requires a completed walk.',
|
|
404
|
+
},
|
|
405
|
+
mode: {
|
|
406
|
+
type: 'string',
|
|
407
|
+
enum: ['rollup', 'gaps'],
|
|
408
|
+
description: 'Output mode. rollup (default): summary + entry points. gaps: conflicts and missing data only.',
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
required: ['issueKey'],
|
|
412
|
+
},
|
|
413
|
+
},
|
|
374
414
|
queue_jira_operations: {
|
|
375
415
|
name: 'queue_jira_operations',
|
|
376
416
|
description: 'Execute multiple Jira operations in a single call. Operations run sequentially with result references ($0.key) and per-operation error strategies (bail/continue). Powerful for analysis pipelines: create a filter, then run multiple analyze_jira_issues calls against $0.filterId with different groupBy/compute — all in one call.',
|
|
@@ -39,7 +39,7 @@ export function issueNextSteps(operation, issueKey) {
|
|
|
39
39
|
steps.push({ description: 'View the linked issue', tool: 'manage_jira_issue', example: { operation: 'get', issueKey } }, { description: 'Read available link types from jira://issue-link-types resource' });
|
|
40
40
|
break;
|
|
41
41
|
case 'hierarchy':
|
|
42
|
-
steps.push({ description: 'View a specific issue from the tree', tool: 'manage_jira_issue', example: { operation: 'get', issueKey } }, { description: 'Search for issues in this project', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql: `project = "${issueKey?.split('-')[0]}"` } });
|
|
42
|
+
steps.push({ description: 'View a specific issue from the tree', tool: 'manage_jira_issue', example: { operation: 'get', issueKey } }, { description: 'Analyze plan rollups (requires Jira Plans)', tool: 'analyze_jira_plan', example: { issueKey } }, { description: 'Search for issues in this project', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql: `project = "${issueKey?.split('-')[0]}"` } });
|
|
43
43
|
break;
|
|
44
44
|
}
|
|
45
45
|
return steps.length > 0 ? formatSteps(steps) : '';
|
|
@@ -125,6 +125,52 @@ export function boardNextSteps(operation, boardId) {
|
|
|
125
125
|
}
|
|
126
126
|
return steps.length > 0 ? formatSteps(steps) : '';
|
|
127
127
|
}
|
|
128
|
+
export function planNextSteps(issueKey, mode, conflicts, rollup) {
|
|
129
|
+
const steps = [];
|
|
130
|
+
steps.push({ description: 'View the issue details', tool: 'manage_jira_issue', example: { operation: 'get', issueKey } }, { description: 'Explore the hierarchy tree', tool: 'manage_jira_issue', example: { operation: 'hierarchy', issueKey } });
|
|
131
|
+
if (mode !== 'gaps') {
|
|
132
|
+
steps.push({ description: 'Check for data gaps and conflicts', tool: 'analyze_jira_plan', example: { issueKey, mode: 'gaps' } });
|
|
133
|
+
}
|
|
134
|
+
if (mode !== 'timeline') {
|
|
135
|
+
steps.push({ description: 'View the timeline', tool: 'analyze_jira_plan', example: { issueKey, mode: 'timeline' } });
|
|
136
|
+
}
|
|
137
|
+
steps.push({ description: 'Run flat metrics on children', tool: 'analyze_jira_issues', example: { jql: `parent = ${issueKey}`, metrics: ['summary'], groupBy: 'assignee' } });
|
|
138
|
+
let result = formatSteps(steps);
|
|
139
|
+
// Append conflict fix operations if conflicts exist
|
|
140
|
+
if (conflicts && conflicts.length > 0 && rollup) {
|
|
141
|
+
result += conflictFixSteps(conflicts, rollup);
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
export function conflictFixSteps(conflicts, rollup) {
|
|
146
|
+
const fixOps = [];
|
|
147
|
+
const lines = ['\n\n**Conflict fixes:**'];
|
|
148
|
+
for (const conflict of conflicts) {
|
|
149
|
+
switch (conflict.type) {
|
|
150
|
+
case 'due_date':
|
|
151
|
+
if (rollup.rolledUpEnd) {
|
|
152
|
+
lines.push(`- Update ${conflict.issueKey} due date to ${rollup.rolledUpEnd} — \`manage_jira_issue\` \`{ operation: "update", issueKey: "${conflict.issueKey}", dueDate: "${rollup.rolledUpEnd}" }\``);
|
|
153
|
+
fixOps.push({ tool: 'manage_jira_issue', args: { operation: 'update', issueKey: conflict.issueKey, dueDate: rollup.rolledUpEnd } });
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
case 'start_date':
|
|
157
|
+
if (rollup.rolledUpStart) {
|
|
158
|
+
lines.push(`- Update ${conflict.issueKey} start date to ${rollup.rolledUpStart} — read \`jira://custom-fields\` to find the start date field ID, then use \`manage_jira_issue update\``);
|
|
159
|
+
// Don't auto-generate queue op for start date — field ID is instance-specific
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
case 'resolved_with_open_children':
|
|
163
|
+
lines.push(`- ${conflict.issueKey}: ${conflict.message} — reopen parent or resolve open children (use \`manage_jira_issue get\` with \`expand: ["transitions"]\` to find transition IDs)`);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (fixOps.length > 0) {
|
|
168
|
+
lines.push('');
|
|
169
|
+
lines.push('**Fix all date conflicts in one call:**');
|
|
170
|
+
lines.push(`\`queue_jira_operations\` — \`${JSON.stringify({ operations: fixOps.map(op => ({ ...op, onError: 'continue' })) })}\``);
|
|
171
|
+
}
|
|
172
|
+
return lines.join('\n');
|
|
173
|
+
}
|
|
128
174
|
export function analysisNextSteps(jql, issueKeys, truncated = false, groupBy, filterSource) {
|
|
129
175
|
const steps = [];
|
|
130
176
|
if (issueKeys.length > 0) {
|
|
@@ -144,6 +190,10 @@ export function analysisNextSteps(jql, issueKeys, truncated = false, groupBy, fi
|
|
|
144
190
|
if (truncated) {
|
|
145
191
|
steps.push({ description: 'Distribution counts above are approximate (issue cap hit). For exact breakdowns use summary + groupBy', tool: 'analyze_jira_issues', example: { jql, metrics: ['summary'], groupBy: 'assignee' } }, { description: 'Or narrow JQL for precise detail metrics', tool: 'analyze_jira_issues', example: { jql: `${jql} AND assignee = currentUser()`, metrics: ['cycle'] } });
|
|
146
192
|
}
|
|
193
|
+
// Suggest plan analysis when issue keys suggest hierarchical structure
|
|
194
|
+
if (issueKeys.length > 0) {
|
|
195
|
+
steps.push({ description: 'Analyze plan rollups for a parent issue (requires Jira Plans)', tool: 'analyze_jira_plan', example: { issueKey: issueKeys[0] } });
|
|
196
|
+
}
|
|
147
197
|
// Suggest saving as filter if not already using one
|
|
148
198
|
if (!filterSource) {
|
|
149
199
|
steps.push({ description: 'Save this query as a filter for reuse across analyses', tool: 'manage_jira_filter', example: { operation: 'create', name: '<descriptive name>', jql } });
|
package/package.json
CHANGED
package/build/worker.js
DELETED
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
import { McpAgent } from 'agents/mcp';
|
|
2
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
-
import { JiraClient } from './client/jira-client.js';
|
|
4
|
-
// Atlassian OAuth handler
|
|
5
|
-
async function atlassianAuthHandler(request, env) {
|
|
6
|
-
const url = new URL(request.url);
|
|
7
|
-
if (url.pathname === '/callback') {
|
|
8
|
-
const code = url.searchParams.get('code');
|
|
9
|
-
const state = url.searchParams.get('state');
|
|
10
|
-
if (!code) {
|
|
11
|
-
return new Response('Missing authorization code', { status: 400 });
|
|
12
|
-
}
|
|
13
|
-
// Exchange code for tokens
|
|
14
|
-
const tokenResponse = await fetch('https://auth.atlassian.com/oauth/token', {
|
|
15
|
-
method: 'POST',
|
|
16
|
-
headers: {
|
|
17
|
-
'Content-Type': 'application/json',
|
|
18
|
-
},
|
|
19
|
-
body: JSON.stringify({
|
|
20
|
-
grant_type: 'authorization_code',
|
|
21
|
-
client_id: env.ATLASSIAN_CLIENT_ID,
|
|
22
|
-
client_secret: env.ATLASSIAN_CLIENT_SECRET,
|
|
23
|
-
code,
|
|
24
|
-
redirect_uri: `${url.origin}/callback`,
|
|
25
|
-
}),
|
|
26
|
-
});
|
|
27
|
-
if (!tokenResponse.ok) {
|
|
28
|
-
const error = await tokenResponse.text();
|
|
29
|
-
return new Response(`Token exchange failed: ${error}`, { status: 400 });
|
|
30
|
-
}
|
|
31
|
-
const tokens = await tokenResponse.json();
|
|
32
|
-
// Get accessible resources (cloud instances)
|
|
33
|
-
const resourcesResponse = await fetch('https://api.atlassian.com/oauth/token/accessible-resources', {
|
|
34
|
-
headers: {
|
|
35
|
-
'Authorization': `Bearer ${tokens.access_token}`,
|
|
36
|
-
'Accept': 'application/json',
|
|
37
|
-
},
|
|
38
|
-
});
|
|
39
|
-
if (!resourcesResponse.ok) {
|
|
40
|
-
return new Response('Failed to get accessible resources', { status: 400 });
|
|
41
|
-
}
|
|
42
|
-
const resources = await resourcesResponse.json();
|
|
43
|
-
if (resources.length === 0) {
|
|
44
|
-
return new Response('No accessible Jira instances found', { status: 400 });
|
|
45
|
-
}
|
|
46
|
-
// Use first available resource (could add selection UI later)
|
|
47
|
-
const cloudId = resources[0].id;
|
|
48
|
-
// Get user info
|
|
49
|
-
const userResponse = await fetch(`https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/myself`, {
|
|
50
|
-
headers: {
|
|
51
|
-
'Authorization': `Bearer ${tokens.access_token}`,
|
|
52
|
-
'Accept': 'application/json',
|
|
53
|
-
},
|
|
54
|
-
});
|
|
55
|
-
const user = await userResponse.json();
|
|
56
|
-
// Store session in KV
|
|
57
|
-
const sessionId = crypto.randomUUID();
|
|
58
|
-
await env.OAUTH_KV.put(`session:${sessionId}`, JSON.stringify({
|
|
59
|
-
accessToken: tokens.access_token,
|
|
60
|
-
refreshToken: tokens.refresh_token,
|
|
61
|
-
cloudId,
|
|
62
|
-
email: user.emailAddress,
|
|
63
|
-
displayName: user.displayName,
|
|
64
|
-
}), { expirationTtl: 3600 * 24 * 7 }); // 7 days
|
|
65
|
-
// Redirect back to original state or home
|
|
66
|
-
const redirectUrl = state || '/';
|
|
67
|
-
return new Response(null, {
|
|
68
|
-
status: 302,
|
|
69
|
-
headers: {
|
|
70
|
-
'Location': redirectUrl,
|
|
71
|
-
'Set-Cookie': `session=${sessionId}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${3600 * 24 * 7}`,
|
|
72
|
-
},
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
if (url.pathname === '/authorize') {
|
|
76
|
-
const state = url.searchParams.get('state') || '/';
|
|
77
|
-
const scopes = [
|
|
78
|
-
'read:jira-work',
|
|
79
|
-
'write:jira-work',
|
|
80
|
-
'read:jira-user',
|
|
81
|
-
'offline_access', // For refresh tokens
|
|
82
|
-
].join(' ');
|
|
83
|
-
const authUrl = new URL('https://auth.atlassian.com/authorize');
|
|
84
|
-
authUrl.searchParams.set('audience', 'api.atlassian.com');
|
|
85
|
-
authUrl.searchParams.set('client_id', env.ATLASSIAN_CLIENT_ID);
|
|
86
|
-
authUrl.searchParams.set('scope', scopes);
|
|
87
|
-
authUrl.searchParams.set('redirect_uri', `${url.origin}/callback`);
|
|
88
|
-
authUrl.searchParams.set('state', state);
|
|
89
|
-
authUrl.searchParams.set('response_type', 'code');
|
|
90
|
-
authUrl.searchParams.set('prompt', 'consent');
|
|
91
|
-
return Response.redirect(authUrl.toString(), 302);
|
|
92
|
-
}
|
|
93
|
-
return new Response('Not found', { status: 404 });
|
|
94
|
-
}
|
|
95
|
-
// MCP Agent for Jira
|
|
96
|
-
export class JiraMCP extends McpAgent {
|
|
97
|
-
server = new McpServer({
|
|
98
|
-
name: 'jira-cloud-mcp',
|
|
99
|
-
version: '0.2.4',
|
|
100
|
-
});
|
|
101
|
-
jiraClient = null;
|
|
102
|
-
async init() {
|
|
103
|
-
// Initialize Jira client with OAuth token
|
|
104
|
-
if (this.props?.accessToken && this.props?.cloudId) {
|
|
105
|
-
this.jiraClient = new JiraClient({
|
|
106
|
-
host: `api.atlassian.com/ex/jira/${this.props.cloudId}`,
|
|
107
|
-
email: this.props.email,
|
|
108
|
-
apiToken: this.props.accessToken, // OAuth token used as bearer
|
|
109
|
-
// Note: We'll need to modify JiraClient to support OAuth bearer tokens
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
// Register tools
|
|
113
|
-
this.registerTools();
|
|
114
|
-
}
|
|
115
|
-
registerTools() {
|
|
116
|
-
// For now, register a simple test tool
|
|
117
|
-
// Full tool registration will come in next iteration
|
|
118
|
-
this.server.tool('test_connection', 'Test the Jira connection', {}, async () => {
|
|
119
|
-
if (!this.jiraClient) {
|
|
120
|
-
return {
|
|
121
|
-
content: [{ type: 'text', text: 'Not authenticated. Please authorize first.' }],
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
return {
|
|
125
|
-
content: [{
|
|
126
|
-
type: 'text',
|
|
127
|
-
text: `Connected as ${this.props?.displayName} (${this.props?.email})`
|
|
128
|
-
}],
|
|
129
|
-
};
|
|
130
|
-
});
|
|
131
|
-
// TODO: Register full Jira tools from existing handlers
|
|
132
|
-
// this.registerIssueTools();
|
|
133
|
-
// this.registerProjectTools();
|
|
134
|
-
// this.registerBoardTools();
|
|
135
|
-
// this.registerSprintTools();
|
|
136
|
-
// this.registerFilterTools();
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
// Main Worker export
|
|
140
|
-
export default {
|
|
141
|
-
async fetch(request, env, ctx) {
|
|
142
|
-
const url = new URL(request.url);
|
|
143
|
-
// Handle OAuth endpoints
|
|
144
|
-
if (url.pathname === '/authorize' || url.pathname === '/callback') {
|
|
145
|
-
return atlassianAuthHandler(request, env);
|
|
146
|
-
}
|
|
147
|
-
// Handle MCP endpoints (SSE and streamable-http)
|
|
148
|
-
if (url.pathname === '/sse' || url.pathname === '/mcp') {
|
|
149
|
-
// Get session from cookie
|
|
150
|
-
const cookies = request.headers.get('Cookie') || '';
|
|
151
|
-
const sessionMatch = cookies.match(/session=([^;]+)/);
|
|
152
|
-
const sessionId = sessionMatch?.[1];
|
|
153
|
-
let props;
|
|
154
|
-
if (sessionId) {
|
|
155
|
-
const sessionData = await env.OAUTH_KV.get(`session:${sessionId}`);
|
|
156
|
-
if (sessionData) {
|
|
157
|
-
props = JSON.parse(sessionData);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
// Route to Durable Object
|
|
161
|
-
const id = env.MCP_OBJECT.idFromName('jira-mcp');
|
|
162
|
-
const stub = env.MCP_OBJECT.get(id);
|
|
163
|
-
// Pass props to the Durable Object
|
|
164
|
-
const newRequest = new Request(request.url, {
|
|
165
|
-
method: request.method,
|
|
166
|
-
headers: request.headers,
|
|
167
|
-
body: request.body,
|
|
168
|
-
});
|
|
169
|
-
// Add props as header for the Durable Object
|
|
170
|
-
if (props) {
|
|
171
|
-
newRequest.headers.set('X-MCP-Props', JSON.stringify(props));
|
|
172
|
-
}
|
|
173
|
-
return stub.fetch(newRequest);
|
|
174
|
-
}
|
|
175
|
-
// Home page with auth status
|
|
176
|
-
const cookies = request.headers.get('Cookie') || '';
|
|
177
|
-
const sessionMatch = cookies.match(/session=([^;]+)/);
|
|
178
|
-
const sessionId = sessionMatch?.[1];
|
|
179
|
-
if (sessionId) {
|
|
180
|
-
const sessionData = await env.OAUTH_KV.get(`session:${sessionId}`);
|
|
181
|
-
if (sessionData) {
|
|
182
|
-
const session = JSON.parse(sessionData);
|
|
183
|
-
return new Response(`
|
|
184
|
-
<h1>Jira Cloud MCP Server</h1>
|
|
185
|
-
<p>Logged in as: ${session.displayName} (${session.email})</p>
|
|
186
|
-
<p>MCP Endpoint: <code>${url.origin}/mcp</code></p>
|
|
187
|
-
<p>SSE Endpoint: <code>${url.origin}/sse</code></p>
|
|
188
|
-
`, {
|
|
189
|
-
headers: { 'Content-Type': 'text/html' },
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return new Response(`
|
|
194
|
-
<h1>Jira Cloud MCP Server</h1>
|
|
195
|
-
<p><a href="/authorize">Authorize with Atlassian</a></p>
|
|
196
|
-
`, {
|
|
197
|
-
headers: { 'Content-Type': 'text/html' },
|
|
198
|
-
});
|
|
199
|
-
},
|
|
200
|
-
};
|