@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/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 analyze_jira_plan is registered if available.
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 !== 'analyze_jira_plan' || this.graphqlClient !== null)
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
- analyze_jira_plan: (_client, req) => handlePlanRequest(this.jiraClient, this.graphqlClient, req, this.cache),
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 — analyze_jira_plan disabled');
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 — analyze_jira_plan disabled');
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 analyze_jira_plan first. Supports all metrics except flow. Takes precedence over jql/filterId.',
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
- analyze_jira_plan: {
412
- name: 'analyze_jira_plan',
413
- 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.',
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 (default): walk hierarchy and compute rollups. release: free cached walk data for this issueKey.',
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. Shows the node, its parent, siblings, and children — a windowed view for navigating large plans. Requires a completed walk.',
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: ['issueKey'],
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: '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]}"` } });
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: 'analyze_jira_plan', example: { issueKey, mode: 'gaps' } });
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: 'analyze_jira_plan', example: { issueKey, mode: 'timeline' } });
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: 'analyze_jira_plan', example: { issueKey: issueKeys[0] } });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
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",