@aaronsb/jira-cloud-mcp 0.8.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/client/graphql-client.js +23 -3
- package/build/client/graphql-goals.js +398 -0
- package/build/client/jira-client.js +41 -0
- package/build/handlers/analysis-handler.js +3 -3
- package/build/handlers/media-handler.js +130 -0
- package/build/handlers/plan-handler.js +308 -3
- package/build/handlers/resource-handlers.js +28 -3
- package/build/handlers/workspace-handler.js +214 -0
- package/build/index.js +12 -7
- package/build/schemas/tool-schemas.js +122 -10
- package/build/utils/next-steps.js +53 -5
- package/build/workspace/index.js +1 -0
- package/build/workspace/workspace.js +187 -0
- package/package.json +1 -1
package/build/index.js
CHANGED
|
@@ -11,14 +11,17 @@ import { handleAnalysisRequest } from './handlers/analysis-handler.js';
|
|
|
11
11
|
import { handleBoardRequest } from './handlers/board-handlers.js';
|
|
12
12
|
import { handleFilterRequest } from './handlers/filter-handlers.js';
|
|
13
13
|
import { handleIssueRequest } from './handlers/issue-handlers.js';
|
|
14
|
+
import { handleMediaRequest } from './handlers/media-handler.js';
|
|
14
15
|
import { handlePlanRequest } from './handlers/plan-handler.js';
|
|
15
16
|
import { handleProjectRequest } from './handlers/project-handlers.js';
|
|
16
17
|
import { createQueueHandler } from './handlers/queue-handler.js';
|
|
17
18
|
import { setupResourceHandlers } from './handlers/resource-handlers.js';
|
|
18
19
|
import { handleSprintRequest } from './handlers/sprint-handlers.js';
|
|
20
|
+
import { handleWorkspaceRequest } from './handlers/workspace-handler.js';
|
|
19
21
|
import { promptDefinitions } from './prompts/prompt-definitions.js';
|
|
20
22
|
import { getPrompt } from './prompts/prompt-messages.js';
|
|
21
23
|
import { toolSchemas } from './schemas/tool-schemas.js';
|
|
24
|
+
import { normalizeArgs } from './utils/normalize-args.js';
|
|
22
25
|
// Jira credentials from environment variables
|
|
23
26
|
const JIRA_EMAIL = process.env.JIRA_EMAIL;
|
|
24
27
|
const JIRA_API_TOKEN = process.env.JIRA_API_TOKEN;
|
|
@@ -102,7 +105,7 @@ class JiraServer {
|
|
|
102
105
|
}
|
|
103
106
|
}).catch(() => { });
|
|
104
107
|
// CloudId discovery happens in run() before server connects — must complete
|
|
105
|
-
// before ListTools so
|
|
108
|
+
// before ListTools so manage_jira_plan is registered if available.
|
|
106
109
|
this.server.onerror = (error) => console.error('[MCP Error]', error);
|
|
107
110
|
process.on('SIGINT', async () => {
|
|
108
111
|
await this.server.close();
|
|
@@ -113,7 +116,7 @@ class JiraServer {
|
|
|
113
116
|
// Set up required MCP protocol handlers
|
|
114
117
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
115
118
|
tools: Object.entries(toolSchemas)
|
|
116
|
-
.filter(([key]) => key !== '
|
|
119
|
+
.filter(([key]) => key !== 'manage_jira_plan' || this.graphqlClient !== null)
|
|
117
120
|
.map(([key, schema]) => ({
|
|
118
121
|
name: key,
|
|
119
122
|
description: schema.description,
|
|
@@ -125,7 +128,7 @@ class JiraServer {
|
|
|
125
128
|
})),
|
|
126
129
|
}));
|
|
127
130
|
// Set up resource handlers
|
|
128
|
-
const resourceHandlers = setupResourceHandlers(this.jiraClient);
|
|
131
|
+
const resourceHandlers = setupResourceHandlers(this.jiraClient, this.graphqlClient);
|
|
129
132
|
this.server.setRequestHandler(ListResourcesRequestSchema, resourceHandlers.listResources);
|
|
130
133
|
this.server.setRequestHandler(ListResourceTemplatesRequestSchema, resourceHandlers.listResourceTemplates);
|
|
131
134
|
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
@@ -160,12 +163,14 @@ class JiraServer {
|
|
|
160
163
|
manage_jira_sprint: handleSprintRequest,
|
|
161
164
|
manage_jira_filter: handleFilterRequest,
|
|
162
165
|
analyze_jira_issues: (client, req) => handleAnalysisRequest(client, req, this.graphqlClient, this.cache),
|
|
166
|
+
manage_jira_media: (client, req) => handleMediaRequest(client, normalizeArgs(req.params.arguments ?? {})),
|
|
167
|
+
manage_workspace: (_client, req) => handleWorkspaceRequest(normalizeArgs(req.params.arguments ?? {})),
|
|
163
168
|
};
|
|
164
169
|
const handlers = {
|
|
165
170
|
...toolHandlers,
|
|
166
171
|
queue_jira_operations: createQueueHandler(toolHandlers, JIRA_HOST),
|
|
167
172
|
...(this.graphqlClient ? {
|
|
168
|
-
|
|
173
|
+
manage_jira_plan: (_client, req) => handlePlanRequest(this.jiraClient, this.graphqlClient, req, this.cache),
|
|
169
174
|
} : {}),
|
|
170
175
|
};
|
|
171
176
|
const handler = handlers[name];
|
|
@@ -293,15 +298,15 @@ class JiraServer {
|
|
|
293
298
|
try {
|
|
294
299
|
const cloudId = await discoverCloudId(JIRA_HOST, JIRA_EMAIL, JIRA_API_TOKEN);
|
|
295
300
|
if (cloudId) {
|
|
296
|
-
this.graphqlClient = new GraphQLClient(JIRA_EMAIL, JIRA_API_TOKEN, cloudId);
|
|
301
|
+
this.graphqlClient = new GraphQLClient(JIRA_EMAIL, JIRA_API_TOKEN, cloudId, JIRA_HOST);
|
|
297
302
|
console.error(`[jira-cloud] GraphQL client ready (cloudId: ${cloudId.slice(0, 8)}...)`);
|
|
298
303
|
}
|
|
299
304
|
else {
|
|
300
|
-
console.error('[jira-cloud] GraphQL/Plans unavailable —
|
|
305
|
+
console.error('[jira-cloud] GraphQL/Plans unavailable — manage_jira_plan disabled');
|
|
301
306
|
}
|
|
302
307
|
}
|
|
303
308
|
catch {
|
|
304
|
-
console.error('[jira-cloud] GraphQL discovery failed —
|
|
309
|
+
console.error('[jira-cloud] GraphQL discovery failed — manage_jira_plan disabled');
|
|
305
310
|
}
|
|
306
311
|
const transport = new StdioServerTransport();
|
|
307
312
|
await this.server.connect(transport);
|
|
@@ -373,7 +373,7 @@ export const toolSchemas = {
|
|
|
373
373
|
},
|
|
374
374
|
dataRef: {
|
|
375
375
|
type: 'string',
|
|
376
|
-
description: 'Root issue key of a cached hierarchy walk. Analyzes cached plan data without re-fetching from Jira. Start a walk with
|
|
376
|
+
description: 'Root issue key of a cached hierarchy walk. Analyzes cached plan data without re-fetching from Jira. Start a walk with manage_jira_plan first. Supports all metrics except flow. Takes precedence over jql/filterId.',
|
|
377
377
|
},
|
|
378
378
|
metrics: {
|
|
379
379
|
type: 'array',
|
|
@@ -408,20 +408,66 @@ export const toolSchemas = {
|
|
|
408
408
|
required: [],
|
|
409
409
|
},
|
|
410
410
|
},
|
|
411
|
-
|
|
412
|
-
name: '
|
|
413
|
-
description: '
|
|
411
|
+
manage_jira_plan: {
|
|
412
|
+
name: 'manage_jira_plan',
|
|
413
|
+
description: 'Navigate and manage the strategic-to-execution hierarchy. Walks issue trees and Atlassian Goals via GraphQL. Read: analyze rollups (dates, points, progress, conflicts), discover goals (list_goals), get goal detail (get_goal). Write: create/update goals, post status updates, link/unlink Jira issues to goals. Results cached server-side. For flat-set metrics use analyze_jira_issues; for structure without rollups use manage_jira_issue hierarchy.',
|
|
414
414
|
inputSchema: {
|
|
415
415
|
type: 'object',
|
|
416
416
|
properties: {
|
|
417
417
|
operation: {
|
|
418
418
|
type: 'string',
|
|
419
|
-
enum: ['analyze', 'release'],
|
|
420
|
-
description: 'Operation to perform. analyze
|
|
419
|
+
enum: ['analyze', 'release', 'list_goals', 'get_goal', 'create_goal', 'update_goal', 'update_goal_status', 'link_work_item', 'unlink_work_item'],
|
|
420
|
+
description: 'Operation to perform. analyze: walk hierarchy and compute rollups. release: free cached walk. list_goals: search goals. get_goal: goal detail. create_goal: create a goal. update_goal: edit goal name/description/dates/archive. update_goal_status: post a status update (on_track/off_track/done). link_work_item: link a Jira issue to a goal. unlink_work_item: unlink a Jira issue from a goal.',
|
|
421
421
|
},
|
|
422
422
|
issueKey: {
|
|
423
423
|
type: 'string',
|
|
424
|
-
description: 'Issue key at the root of the plan tree (e.g., PROJ-100). Required.',
|
|
424
|
+
description: 'Issue key at the root of the plan tree (e.g., PROJ-100). Required for analyze/release unless goalKey is provided. Also used with link_work_item/unlink_work_item to specify the Jira issue.',
|
|
425
|
+
},
|
|
426
|
+
goalKey: {
|
|
427
|
+
type: 'string',
|
|
428
|
+
description: 'Atlassian Goal key (e.g., PRAEC-25). Used for get_goal, analyze, update_goal, update_goal_status, link_work_item, unlink_work_item.',
|
|
429
|
+
},
|
|
430
|
+
name: {
|
|
431
|
+
type: 'string',
|
|
432
|
+
description: 'Goal name. Required for create_goal. Optional for update_goal (renames the goal).',
|
|
433
|
+
},
|
|
434
|
+
description: {
|
|
435
|
+
type: 'string',
|
|
436
|
+
description: 'Goal description text. For create_goal and update_goal.',
|
|
437
|
+
},
|
|
438
|
+
status: {
|
|
439
|
+
type: 'string',
|
|
440
|
+
enum: ['on_track', 'off_track', 'at_risk', 'done', 'pending', 'paused'],
|
|
441
|
+
description: 'Goal status for update_goal_status.',
|
|
442
|
+
},
|
|
443
|
+
summary: {
|
|
444
|
+
type: 'string',
|
|
445
|
+
description: 'Status update summary text for update_goal_status. Describes what changed and why.',
|
|
446
|
+
},
|
|
447
|
+
parentGoalKey: {
|
|
448
|
+
type: 'string',
|
|
449
|
+
description: 'Parent goal key for create_goal. Makes the new goal a sub-goal of this parent.',
|
|
450
|
+
},
|
|
451
|
+
targetDate: {
|
|
452
|
+
type: 'string',
|
|
453
|
+
description: 'Target date in ISO format (YYYY-MM-DD) for create_goal and update_goal.',
|
|
454
|
+
},
|
|
455
|
+
startDate: {
|
|
456
|
+
type: 'string',
|
|
457
|
+
description: 'Start date in ISO format (YYYY-MM-DD) for update_goal.',
|
|
458
|
+
},
|
|
459
|
+
archived: {
|
|
460
|
+
type: 'boolean',
|
|
461
|
+
description: 'Set to true to archive a goal, false to unarchive. For update_goal.',
|
|
462
|
+
},
|
|
463
|
+
searchString: {
|
|
464
|
+
type: 'string',
|
|
465
|
+
description: 'TQL search string for list_goals. Examples: \'name LIKE "Health"\', \'status = on_track\'. Empty string returns all goals.',
|
|
466
|
+
},
|
|
467
|
+
sort: {
|
|
468
|
+
type: 'string',
|
|
469
|
+
enum: ['HIERARCHY_ASC', 'HIERARCHY_DESC', 'NAME_ASC', 'NAME_DESC', 'TARGET_DATE_ASC', 'TARGET_DATE_DESC', 'LATEST_UPDATE_DATE_ASC', 'LATEST_UPDATE_DATE_DESC'],
|
|
470
|
+
description: 'Sort order for list_goals. Default: HIERARCHY_ASC (groups parents with children).',
|
|
425
471
|
},
|
|
426
472
|
rollups: {
|
|
427
473
|
type: 'array',
|
|
@@ -433,7 +479,7 @@ export const toolSchemas = {
|
|
|
433
479
|
},
|
|
434
480
|
focus: {
|
|
435
481
|
type: 'string',
|
|
436
|
-
description: 'Issue key to focus on within the cached plan.
|
|
482
|
+
description: 'Issue key to focus on within the cached plan. Windowed view for navigating large plans.',
|
|
437
483
|
},
|
|
438
484
|
mode: {
|
|
439
485
|
type: 'string',
|
|
@@ -441,7 +487,73 @@ export const toolSchemas = {
|
|
|
441
487
|
description: 'Output mode. rollup (default): summary + entry points. gaps: conflicts and missing data only.',
|
|
442
488
|
},
|
|
443
489
|
},
|
|
444
|
-
required: [
|
|
490
|
+
required: [],
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
manage_jira_media: {
|
|
494
|
+
name: 'manage_jira_media',
|
|
495
|
+
description: 'Manage file attachments on Jira issues (remote). Operations here affect Jira — delete permanently removes an attachment from the issue for all users. Use manage_workspace for local file staging. Downloads copy from Jira to workspace; uploads copy from workspace to Jira.',
|
|
496
|
+
inputSchema: {
|
|
497
|
+
type: 'object',
|
|
498
|
+
properties: {
|
|
499
|
+
operation: {
|
|
500
|
+
type: 'string',
|
|
501
|
+
enum: ['list', 'upload', 'download', 'view', 'get_info', 'delete'],
|
|
502
|
+
description: 'Operation to perform. list: attachments on an issue. upload: copy file from workspace to Jira issue. download: copy attachment from Jira to local workspace. view: display image inline. get_info: attachment metadata. delete: permanently remove attachment from Jira (affects all users).',
|
|
503
|
+
},
|
|
504
|
+
issueKey: {
|
|
505
|
+
type: 'string',
|
|
506
|
+
description: 'Issue key (e.g., PROJ-123). Required for list and upload.',
|
|
507
|
+
},
|
|
508
|
+
attachmentId: {
|
|
509
|
+
type: 'string',
|
|
510
|
+
description: 'Attachment ID. Required for download, view, get_info, delete.',
|
|
511
|
+
},
|
|
512
|
+
filename: {
|
|
513
|
+
type: 'string',
|
|
514
|
+
description: 'Filename for upload (required) or download (optional override).',
|
|
515
|
+
},
|
|
516
|
+
content: {
|
|
517
|
+
type: 'string',
|
|
518
|
+
description: 'Base64-encoded file content for upload. Alternative to workspaceFile.',
|
|
519
|
+
},
|
|
520
|
+
mediaType: {
|
|
521
|
+
type: 'string',
|
|
522
|
+
description: 'MIME type (e.g., "image/png", "application/pdf"). Required for upload.',
|
|
523
|
+
},
|
|
524
|
+
workspaceFile: {
|
|
525
|
+
type: 'string',
|
|
526
|
+
description: 'Filename in workspace to upload. Alternative to content. Use manage_workspace list to see staged files.',
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
required: ['operation'],
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
manage_workspace: {
|
|
533
|
+
name: 'manage_workspace',
|
|
534
|
+
description: 'Manage files in the local workspace staging area (local only — no Jira impact). Files downloaded via manage_jira_media land here. Delete only removes the local copy. Use manage_jira_media to affect attachments on Jira issues.',
|
|
535
|
+
inputSchema: {
|
|
536
|
+
type: 'object',
|
|
537
|
+
properties: {
|
|
538
|
+
operation: {
|
|
539
|
+
type: 'string',
|
|
540
|
+
enum: ['list', 'read', 'write', 'delete', 'mkdir', 'move'],
|
|
541
|
+
description: 'Operation to perform. list: show staged files. read: display file content. write: stage base64 content. delete: remove local file only (does not affect Jira). mkdir: create directory. move: rename/relocate file.',
|
|
542
|
+
},
|
|
543
|
+
filename: {
|
|
544
|
+
type: 'string',
|
|
545
|
+
description: 'Filename or path within workspace. Supports nesting with / separators.',
|
|
546
|
+
},
|
|
547
|
+
destination: {
|
|
548
|
+
type: 'string',
|
|
549
|
+
description: 'Destination path for move operation.',
|
|
550
|
+
},
|
|
551
|
+
content: {
|
|
552
|
+
type: 'string',
|
|
553
|
+
description: 'Base64-encoded content for write operation.',
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
required: ['operation'],
|
|
445
557
|
},
|
|
446
558
|
},
|
|
447
559
|
queue_jira_operations: {
|
|
@@ -457,7 +569,7 @@ export const toolSchemas = {
|
|
|
457
569
|
properties: {
|
|
458
570
|
tool: {
|
|
459
571
|
type: 'string',
|
|
460
|
-
enum: ['manage_jira_issue', 'manage_jira_filter', 'manage_jira_sprint', 'manage_jira_project', 'manage_jira_board', 'analyze_jira_issues'],
|
|
572
|
+
enum: ['manage_jira_issue', 'manage_jira_filter', 'manage_jira_sprint', 'manage_jira_project', 'manage_jira_board', 'analyze_jira_issues', 'manage_jira_media', 'manage_workspace'],
|
|
461
573
|
description: 'Which tool to call.',
|
|
462
574
|
},
|
|
463
575
|
args: {
|
|
@@ -18,7 +18,7 @@ export function issueNextSteps(operation, issueKey) {
|
|
|
18
18
|
steps.push({ description: 'Transition to a new status', tool: 'manage_jira_issue', example: { operation: 'transition', issueKey, expand: ['transitions'] } }, { description: 'Add to a sprint', tool: 'manage_jira_sprint', example: { operation: 'manage_issues', sprintId: '<id>', add: [issueKey] } }, { description: 'Link to a related issue', tool: 'manage_jira_issue', example: { operation: 'link', issueKey, linkedIssueKey: '<key>', linkType: 'relates to' } }, { description: 'Read jira://custom-fields to discover available custom fields for this instance' });
|
|
19
19
|
break;
|
|
20
20
|
case 'get':
|
|
21
|
-
steps.push({ description: 'Update fields', tool: 'manage_jira_issue', example: { operation: 'update', issueKey } }, { description: 'Add a comment', tool: 'manage_jira_issue', example: { operation: 'comment', issueKey, comment: '<text>' } }, { description: 'View available transitions', tool: 'manage_jira_issue', example: { operation: 'get', issueKey, expand: ['transitions'] } });
|
|
21
|
+
steps.push({ description: 'Update fields', tool: 'manage_jira_issue', example: { operation: 'update', issueKey } }, { description: 'Add a comment', tool: 'manage_jira_issue', example: { operation: 'comment', issueKey, comment: '<text>' } }, { description: 'View available transitions', tool: 'manage_jira_issue', example: { operation: 'get', issueKey, expand: ['transitions'] } }, { description: 'Manage attachments (upload, download, view)', tool: 'manage_jira_media', example: { operation: 'list', issueKey } });
|
|
22
22
|
break;
|
|
23
23
|
case 'update':
|
|
24
24
|
steps.push({ description: 'View the updated issue', tool: 'manage_jira_issue', example: { operation: 'get', issueKey } }, { description: 'Transition to a new status', tool: 'manage_jira_issue', example: { operation: 'get', issueKey, expand: ['transitions'] } }, { description: 'Read jira://custom-fields to discover available custom fields for this instance' });
|
|
@@ -42,7 +42,7 @@ export function issueNextSteps(operation, issueKey) {
|
|
|
42
42
|
steps.push({ description: 'View the updated issue', tool: 'manage_jira_issue', example: { operation: 'get', issueKey } }, { description: 'Log more time', tool: 'manage_jira_issue', example: { operation: 'worklog', issueKey, timeSpent: '<duration>' } }, { description: 'Adjust the remaining estimate', tool: 'manage_jira_issue', example: { operation: 'update', issueKey, remainingEstimate: '<duration>' } });
|
|
43
43
|
break;
|
|
44
44
|
case 'hierarchy':
|
|
45
|
-
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: '
|
|
45
|
+
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: 'manage_jira_plan', example: { issueKey } }, { description: 'Search for issues in this project', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql: `project = "${issueKey?.split('-')[0]}"` } });
|
|
46
46
|
break;
|
|
47
47
|
}
|
|
48
48
|
return steps.length > 0 ? formatSteps(steps) : '';
|
|
@@ -132,10 +132,10 @@ export function planNextSteps(issueKey, mode, conflicts, rollup) {
|
|
|
132
132
|
const steps = [];
|
|
133
133
|
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 } });
|
|
134
134
|
if (mode !== 'gaps') {
|
|
135
|
-
steps.push({ description: 'Check for data gaps and conflicts', tool: '
|
|
135
|
+
steps.push({ description: 'Check for data gaps and conflicts', tool: 'manage_jira_plan', example: { issueKey, mode: 'gaps' } });
|
|
136
136
|
}
|
|
137
137
|
if (mode !== 'timeline') {
|
|
138
|
-
steps.push({ description: 'View the timeline', tool: '
|
|
138
|
+
steps.push({ description: 'View the timeline', tool: 'manage_jira_plan', example: { issueKey, mode: 'timeline' } });
|
|
139
139
|
}
|
|
140
140
|
steps.push({ description: 'Run flat metrics on children', tool: 'analyze_jira_issues', example: { jql: `parent = ${issueKey}`, metrics: ['summary'], groupBy: 'assignee' } });
|
|
141
141
|
let result = formatSteps(steps);
|
|
@@ -174,6 +174,54 @@ export function conflictFixSteps(conflicts, rollup) {
|
|
|
174
174
|
}
|
|
175
175
|
return lines.join('\n');
|
|
176
176
|
}
|
|
177
|
+
export function goalNextSteps(operation, goalKey, workItemCount) {
|
|
178
|
+
const steps = [];
|
|
179
|
+
switch (operation) {
|
|
180
|
+
case 'list_goals':
|
|
181
|
+
steps.push({ description: 'Get detail on a specific goal', tool: 'manage_jira_plan', example: { operation: 'get_goal', goalKey: 'GOAL-KEY' } }, { description: 'Analyze a goal\'s linked issues', tool: 'manage_jira_plan', example: { operation: 'analyze', goalKey: 'GOAL-KEY' } });
|
|
182
|
+
break;
|
|
183
|
+
case 'get_goal':
|
|
184
|
+
if (goalKey && workItemCount && workItemCount > 0) {
|
|
185
|
+
steps.push({ description: 'Analyze this goal\'s linked issues', tool: 'manage_jira_plan', example: { operation: 'analyze', goalKey } });
|
|
186
|
+
}
|
|
187
|
+
steps.push({ description: 'Search for more goals', tool: 'manage_jira_plan', example: { operation: 'list_goals', searchString: '' } });
|
|
188
|
+
break;
|
|
189
|
+
case 'analyze':
|
|
190
|
+
if (goalKey) {
|
|
191
|
+
steps.push({ description: 'View goal detail', tool: 'manage_jira_plan', example: { operation: 'get_goal', goalKey } }, { description: 'Update goal status', tool: 'manage_jira_plan', example: { operation: 'update_goal_status', goalKey, status: 'on_track', summary: 'Progress update' } });
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
case 'create_goal':
|
|
195
|
+
case 'update_goal':
|
|
196
|
+
case 'update_goal_status':
|
|
197
|
+
if (goalKey) {
|
|
198
|
+
steps.push({ description: 'View updated goal', tool: 'manage_jira_plan', example: { operation: 'get_goal', goalKey } });
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
case 'link_work_item':
|
|
202
|
+
case 'unlink_work_item':
|
|
203
|
+
if (goalKey) {
|
|
204
|
+
steps.push({ description: 'View goal with updated links', tool: 'manage_jira_plan', example: { operation: 'get_goal', goalKey } }, { description: 'Analyze goal progress', tool: 'manage_jira_plan', example: { operation: 'analyze', goalKey } });
|
|
205
|
+
}
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
return steps.length > 0 ? formatSteps(steps) : '';
|
|
209
|
+
}
|
|
210
|
+
export function mediaNextSteps(operation, context) {
|
|
211
|
+
const steps = [];
|
|
212
|
+
switch (operation) {
|
|
213
|
+
case 'list':
|
|
214
|
+
steps.push({ description: 'Download an attachment to workspace', tool: 'manage_jira_media', example: { operation: 'download', attachmentId: '<id>' } }, { description: 'View an image attachment inline', tool: 'manage_jira_media', example: { operation: 'view', attachmentId: '<id>' } }, { description: 'Upload a file to this issue', tool: 'manage_jira_media', example: { operation: 'upload', issueKey: context.issueKey, filename: '<name>', mediaType: '<mime>', workspaceFile: '<staged file>' } });
|
|
215
|
+
break;
|
|
216
|
+
case 'upload':
|
|
217
|
+
steps.push({ description: 'List attachments on this issue', tool: 'manage_jira_media', example: { operation: 'list', issueKey: context.issueKey } }, { description: 'View the issue', tool: 'manage_jira_issue', example: { operation: 'get', issueKey: context.issueKey } });
|
|
218
|
+
break;
|
|
219
|
+
case 'download':
|
|
220
|
+
steps.push({ description: 'View staged files in workspace', tool: 'manage_workspace', example: { operation: 'list' } }, { description: 'Upload to another issue', tool: 'manage_jira_media', example: { operation: 'upload', issueKey: '<target issue>', workspaceFile: '<filename>', mediaType: '<mime>', filename: '<filename>' } }, { description: 'Read the downloaded file', tool: 'manage_workspace', example: { operation: 'read', filename: '<filename>' } });
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
return steps.length > 0 ? formatSteps(steps) : '';
|
|
224
|
+
}
|
|
177
225
|
export function analysisNextSteps(jql, issueKeys, truncated = false, groupBy, filterSource) {
|
|
178
226
|
const steps = [];
|
|
179
227
|
if (issueKeys.length > 0) {
|
|
@@ -195,7 +243,7 @@ export function analysisNextSteps(jql, issueKeys, truncated = false, groupBy, fi
|
|
|
195
243
|
}
|
|
196
244
|
// Suggest plan analysis when issue keys suggest hierarchical structure
|
|
197
245
|
if (issueKeys.length > 0) {
|
|
198
|
-
steps.push({ description: 'Analyze plan rollups for a parent issue (requires Jira Plans)', tool: '
|
|
246
|
+
steps.push({ description: 'Analyze plan rollups for a parent issue (requires Jira Plans)', tool: 'manage_jira_plan', example: { issueKey: issueKeys[0] } });
|
|
199
247
|
}
|
|
200
248
|
// Suggest saving as filter if not already using one
|
|
201
249
|
if (!filterSource) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { checkWorkspaceStatus, dataDir, ensureParentDir, ensureWorkspaceDir, formatSize, getWorkspaceDir, resolveWorkspacePath, sanitizeFilename, sanitizePath, validateWorkspaceDir, verifyPathSafety, } from './workspace.js';
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace directory — safe sandbox for file staging operations.
|
|
3
|
+
*
|
|
4
|
+
* All file operations (attachment download, upload, media staging) are jailed
|
|
5
|
+
* to this directory. Prevents agents from accidentally operating on home
|
|
6
|
+
* directories, document folders, or cloud sync mount points.
|
|
7
|
+
*
|
|
8
|
+
* See ADR-211: Attachment and Workspace Management.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'node:fs/promises';
|
|
11
|
+
import * as os from 'node:os';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
const APP_NAME = 'jira-cloud-mcp';
|
|
14
|
+
// ── XDG Paths ──────────────────────────────────────────────
|
|
15
|
+
export function dataDir() {
|
|
16
|
+
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
|
17
|
+
return path.join(base, APP_NAME);
|
|
18
|
+
}
|
|
19
|
+
// ── Forbidden Paths ────────────────────────────────────────
|
|
20
|
+
/** Paths that must never be used as the workspace root. */
|
|
21
|
+
const FORBIDDEN_PATHS = [
|
|
22
|
+
() => process.env.HOME ?? '',
|
|
23
|
+
() => process.env.USERPROFILE ?? '',
|
|
24
|
+
() => process.env.HOME ? path.join(process.env.HOME, 'Documents') : '',
|
|
25
|
+
() => process.env.HOME ? path.join(process.env.HOME, 'Desktop') : '',
|
|
26
|
+
() => process.env.HOME ? path.join(process.env.HOME, 'Downloads') : '',
|
|
27
|
+
() => process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'Documents') : '',
|
|
28
|
+
() => process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'Desktop') : '',
|
|
29
|
+
() => process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'Downloads') : '',
|
|
30
|
+
];
|
|
31
|
+
/** Path substrings that indicate a cloud sync mount. */
|
|
32
|
+
const CLOUD_SYNC_PATTERNS = [
|
|
33
|
+
'google-drive',
|
|
34
|
+
'Google Drive',
|
|
35
|
+
'GoogleDrive',
|
|
36
|
+
'gdrive',
|
|
37
|
+
'My Drive',
|
|
38
|
+
'OneDrive',
|
|
39
|
+
'onedrive',
|
|
40
|
+
'Dropbox',
|
|
41
|
+
'dropbox',
|
|
42
|
+
'iCloud Drive',
|
|
43
|
+
'iCloudDrive',
|
|
44
|
+
];
|
|
45
|
+
// ── Workspace Directory ────────────────────────────────────
|
|
46
|
+
/** Get the workspace directory path, respecting env overrides. */
|
|
47
|
+
export function getWorkspaceDir() {
|
|
48
|
+
const configured = process.env.WORKSPACE_DIR;
|
|
49
|
+
if (configured && !configured.includes('${')) {
|
|
50
|
+
return configured;
|
|
51
|
+
}
|
|
52
|
+
return path.join(dataDir(), 'workspace');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Validate workspace dir is safe. Throws if it IS a protected directory.
|
|
56
|
+
* Being a subdirectory OF a protected directory is fine.
|
|
57
|
+
*/
|
|
58
|
+
export function validateWorkspaceDir(dir) {
|
|
59
|
+
const resolved = path.resolve(dir);
|
|
60
|
+
for (const getForbidden of FORBIDDEN_PATHS) {
|
|
61
|
+
const forbidden = getForbidden();
|
|
62
|
+
if (forbidden && path.resolve(forbidden) === resolved) {
|
|
63
|
+
throw new Error(`Workspace directory cannot be ${resolved} — use a subdirectory like ${getWorkspaceDir()}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const pattern of CLOUD_SYNC_PATTERNS) {
|
|
67
|
+
if (resolved.toLowerCase().includes(pattern.toLowerCase())) {
|
|
68
|
+
throw new Error(`Workspace directory cannot be inside a cloud sync mount (${resolved}) — this could cause sync conflicts`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (resolved === '/' || resolved === 'C:\\') {
|
|
72
|
+
throw new Error('Workspace directory cannot be the filesystem root');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Check workspace directory status without throwing. */
|
|
76
|
+
export function checkWorkspaceStatus() {
|
|
77
|
+
const dir = getWorkspaceDir();
|
|
78
|
+
try {
|
|
79
|
+
validateWorkspaceDir(dir);
|
|
80
|
+
return { path: dir, valid: true };
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
return { path: dir, valid: false, warning: err.message };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Ensure the workspace directory exists and is validated. */
|
|
87
|
+
export async function ensureWorkspaceDir() {
|
|
88
|
+
const status = checkWorkspaceStatus();
|
|
89
|
+
if (status.valid) {
|
|
90
|
+
await fs.mkdir(status.path, { recursive: true, mode: 0o755 });
|
|
91
|
+
}
|
|
92
|
+
return status;
|
|
93
|
+
}
|
|
94
|
+
// ── Filename Sanitization ──────────────────────────────────
|
|
95
|
+
/**
|
|
96
|
+
* Sanitize a single filename segment (no separators).
|
|
97
|
+
* Strips null bytes, control characters, path separators, and dangerous chars.
|
|
98
|
+
*/
|
|
99
|
+
export function sanitizeFilename(filename) {
|
|
100
|
+
return filename
|
|
101
|
+
// Remove null bytes and control characters
|
|
102
|
+
// eslint-disable-next-line no-control-regex
|
|
103
|
+
.replace(/[\x00-\x1f\x7f]/g, '')
|
|
104
|
+
// Remove path separators
|
|
105
|
+
.replace(/[/\\]/g, '_')
|
|
106
|
+
// Remove other dangerous characters
|
|
107
|
+
.replace(/[<>:"|?*]/g, '_')
|
|
108
|
+
// Collapse multiple underscores
|
|
109
|
+
.replace(/_+/g, '_')
|
|
110
|
+
// Remove leading dots (hidden files) and trailing dots/spaces
|
|
111
|
+
.replace(/^\.+/, '')
|
|
112
|
+
.replace(/[. ]+$/, '')
|
|
113
|
+
|| 'unnamed';
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Sanitize a workspace path that may contain directory separators.
|
|
117
|
+
* Each path segment is sanitized individually, preserving the directory structure.
|
|
118
|
+
* Empty segments and traversal attempts (e.g. '..') are removed.
|
|
119
|
+
*/
|
|
120
|
+
export function sanitizePath(filePath) {
|
|
121
|
+
// Normalize separators to forward slash, split into segments
|
|
122
|
+
const segments = filePath
|
|
123
|
+
.replace(/\\/g, '/')
|
|
124
|
+
.split('/')
|
|
125
|
+
.map(seg => sanitizeFilename(seg))
|
|
126
|
+
// Drop segments that sanitized to 'unnamed' (e.g. '..' → '' → 'unnamed').
|
|
127
|
+
// Only keep 'unnamed' if the entire input was empty (fallback sentinel).
|
|
128
|
+
.filter(seg => seg !== 'unnamed' || filePath.trim() === '');
|
|
129
|
+
if (segments.length === 0)
|
|
130
|
+
return 'unnamed';
|
|
131
|
+
return segments.join(path.sep);
|
|
132
|
+
}
|
|
133
|
+
// ── Path Resolution ────────────────────────────────────────
|
|
134
|
+
/**
|
|
135
|
+
* Resolve a file path within the workspace directory.
|
|
136
|
+
* Supports nested paths (e.g. 'projects/report.csv').
|
|
137
|
+
* Prevents path traversal and sanitizes each path segment.
|
|
138
|
+
*/
|
|
139
|
+
export function resolveWorkspacePath(filePath) {
|
|
140
|
+
const dir = getWorkspaceDir();
|
|
141
|
+
// Use sanitizePath for nested paths, sanitizeFilename for flat filenames
|
|
142
|
+
const sanitized = filePath.includes('/') || filePath.includes('\\')
|
|
143
|
+
? sanitizePath(filePath)
|
|
144
|
+
: sanitizeFilename(filePath);
|
|
145
|
+
const resolved = path.resolve(dir, sanitized);
|
|
146
|
+
const resolvedDir = path.resolve(dir);
|
|
147
|
+
if (!resolved.startsWith(resolvedDir + path.sep) && resolved !== resolvedDir) {
|
|
148
|
+
throw new Error(`Path traversal detected: "${filePath}" resolves outside workspace directory`);
|
|
149
|
+
}
|
|
150
|
+
return resolved;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Ensure parent directories exist for a workspace path.
|
|
154
|
+
* Call after resolveWorkspacePath to create intermediate dirs.
|
|
155
|
+
*/
|
|
156
|
+
export async function ensureParentDir(filePath) {
|
|
157
|
+
const dir = path.dirname(filePath);
|
|
158
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o755 });
|
|
159
|
+
}
|
|
160
|
+
// ── Formatting ─────────────────────────────────────────────
|
|
161
|
+
/** Format byte count as human-readable size. */
|
|
162
|
+
export function formatSize(bytes) {
|
|
163
|
+
if (bytes < 1024)
|
|
164
|
+
return `${bytes}B`;
|
|
165
|
+
if (bytes < 1024 * 1024)
|
|
166
|
+
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
167
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
168
|
+
}
|
|
169
|
+
// ── Path Safety ────────────────────────────────────────────
|
|
170
|
+
/**
|
|
171
|
+
* Verify a file path is safe after symlink resolution.
|
|
172
|
+
* Must be called before any fs operation on a workspace path.
|
|
173
|
+
*/
|
|
174
|
+
export async function verifyPathSafety(filePath) {
|
|
175
|
+
const dir = path.resolve(getWorkspaceDir());
|
|
176
|
+
try {
|
|
177
|
+
const real = await fs.realpath(filePath);
|
|
178
|
+
if (!real.startsWith(dir + path.sep) && real !== dir) {
|
|
179
|
+
throw new Error(`Symlink escape detected: "${filePath}" resolves to "${real}" outside workspace`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
if (err.code === 'ENOENT')
|
|
184
|
+
return;
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
}
|
package/package.json
CHANGED