@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/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 filterId is required (filterId takes precedence). Examples: "project in (AA, GC, LGS)", "sprint in openSprints()", "assignee = currentUser() AND resolution = Unresolved".',
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 flow explicitly). For counting/breakdown questions, always prefer summary + groupBy over distribution.',
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.5.11",
3
+ "version": "0.6.1",
4
4
  "mcpName": "io.github.aaronsb/jira-cloud",
5
5
  "description": "Model Context Protocol (MCP) server for Jira Cloud - enables AI assistants to interact with Jira",
6
6
  "type": "module",
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
- };