@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.
@@ -57,22 +57,42 @@ export async function discoverCloudId(host, email, apiToken) {
57
57
  export class GraphQLClient {
58
58
  authHeader;
59
59
  cloudId;
60
- constructor(email, apiToken, cloudId) {
60
+ tenantedEndpoint;
61
+ constructor(email, apiToken, cloudId, host) {
61
62
  this.authHeader = buildAuthHeader(email, apiToken);
62
63
  this.cloudId = cloudId;
64
+ this.tenantedEndpoint = host
65
+ ? `https://${extractHostname(host)}/gateway/api/graphql`
66
+ : null;
63
67
  }
64
68
  getCloudId() {
65
69
  return this.cloudId;
66
70
  }
71
+ /** Site container ARI for Townsquare queries */
72
+ getSiteAri() {
73
+ return `ari:cloud:townsquare::site/${this.cloudId}`;
74
+ }
67
75
  async query(query, variables = {}) {
76
+ return this._fetch(AGG_ENDPOINT, query, variables, {
77
+ 'X-ExperimentalApi': 'JiraPlan,JiraPlansSupport',
78
+ });
79
+ }
80
+ /** Query the tenanted endpoint ({host}/gateway/api/graphql) for site-scoped APIs like Townsquare */
81
+ async queryTenanted(query, variables = {}) {
82
+ if (!this.tenantedEndpoint) {
83
+ return { success: false, error: 'Tenanted endpoint not available (no host configured)' };
84
+ }
85
+ return this._fetch(this.tenantedEndpoint, query, variables);
86
+ }
87
+ async _fetch(endpoint, query, variables, extraHeaders) {
68
88
  try {
69
- const response = await fetch(AGG_ENDPOINT, {
89
+ const response = await fetch(endpoint, {
70
90
  method: 'POST',
71
91
  headers: {
72
92
  'Authorization': this.authHeader,
73
93
  'Content-Type': 'application/json',
74
94
  'Accept': 'application/json',
75
- 'X-ExperimentalApi': 'JiraPlan,JiraPlansSupport',
95
+ ...extraHeaders,
76
96
  },
77
97
  body: JSON.stringify({
78
98
  query,
@@ -0,0 +1,398 @@
1
+ // --- Queries ---
2
+ const GOALS_SEARCH_QUERY = `
3
+ query GoalsSearch($containerId: ID!, $searchString: String!, $sort: [TownsquareGoalSortEnum], $first: Int, $after: String) {
4
+ goals_search(containerId: $containerId, searchString: $searchString, sort: $sort, first: $first, after: $after) {
5
+ edges {
6
+ node {
7
+ id
8
+ name
9
+ key
10
+ url
11
+ state { value label }
12
+ owner { name }
13
+ parentGoal { name key }
14
+ subGoals(first: 50) {
15
+ edges { node { name key state { value } } }
16
+ }
17
+ workItems(first: 50) @optIn(to: "GraphStoreJiraEpicContributesToAtlasGoal") {
18
+ edges { node { ... on JiraIssue { key } } }
19
+ }
20
+ }
21
+ }
22
+ pageInfo {
23
+ hasNextPage
24
+ endCursor
25
+ }
26
+ }
27
+ }
28
+ `;
29
+ const GOAL_BY_KEY_QUERY = `
30
+ query GoalByKey($containerId: ID!, $goalKey: String!) {
31
+ goals_byKey(containerId: $containerId, goalKey: $goalKey) {
32
+ id
33
+ name
34
+ key
35
+ url
36
+ state { value label }
37
+ owner { name }
38
+ parentGoal { name key }
39
+ description
40
+ subGoals(first: 50) {
41
+ edges {
42
+ node {
43
+ id name key url
44
+ state { value label }
45
+ owner { name }
46
+ parentGoal { name key }
47
+ description
48
+ }
49
+ }
50
+ }
51
+ projects(first: 20) {
52
+ edges { node { name state { value } } }
53
+ }
54
+ workItems(first: 100) @optIn(to: "GraphStoreJiraEpicContributesToAtlasGoal") {
55
+ edges {
56
+ node {
57
+ ... on JiraIssue {
58
+ key
59
+ summary
60
+ status { name }
61
+ issueType { name }
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
67
+ }
68
+ `;
69
+ // --- Helpers ---
70
+ /** Extract plain text from an ADF JSON string or return as-is if not ADF */
71
+ function extractAdfText(raw) {
72
+ if (!raw)
73
+ return null;
74
+ try {
75
+ const doc = JSON.parse(raw);
76
+ if (doc?.type !== 'doc' || !Array.isArray(doc.content))
77
+ return raw;
78
+ return extractTextFromNodes(doc.content).trim() || null;
79
+ }
80
+ catch {
81
+ return raw;
82
+ }
83
+ }
84
+ function extractTextFromNodes(nodes) {
85
+ let text = '';
86
+ for (const node of nodes) {
87
+ if (node.text)
88
+ text += node.text;
89
+ if (Array.isArray(node.content))
90
+ text += extractTextFromNodes(node.content);
91
+ if (node.type === 'paragraph' || node.type === 'heading')
92
+ text += '\n';
93
+ if (node.type === 'hardBreak')
94
+ text += '\n';
95
+ if (node.type === 'listItem')
96
+ text += '- ';
97
+ }
98
+ return text;
99
+ }
100
+ /** Wrap plain text in ADF document format (required for Townsquare description/summary fields) */
101
+ function toAdf(text) {
102
+ return JSON.stringify({
103
+ version: 1, type: 'doc',
104
+ content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
105
+ });
106
+ }
107
+ // --- Functions ---
108
+ export async function searchGoals(client, searchString, sort = 'HIERARCHY_ASC', first = 50) {
109
+ const result = await client.queryTenanted(GOALS_SEARCH_QUERY, {
110
+ containerId: client.getSiteAri(),
111
+ searchString,
112
+ sort: [sort],
113
+ first,
114
+ });
115
+ if (!result.success || !result.data) {
116
+ return { success: false, error: result.error ?? 'No data returned' };
117
+ }
118
+ const edges = result.data.goals_search.edges;
119
+ const goals = edges.map(e => ({
120
+ id: e.node.id,
121
+ name: e.node.name,
122
+ key: e.node.key,
123
+ url: e.node.url,
124
+ state: e.node.state,
125
+ owner: e.node.owner,
126
+ parentGoal: e.node.parentGoal,
127
+ description: null,
128
+ }));
129
+ const workItemCounts = new Map();
130
+ for (const e of edges) {
131
+ const count = e.node.workItems?.edges?.filter(w => w.node.key).length ?? 0;
132
+ workItemCounts.set(e.node.key, count);
133
+ }
134
+ return { success: true, goals, workItemCounts };
135
+ }
136
+ export async function getGoalByKey(client, goalKey) {
137
+ const result = await client.queryTenanted(GOAL_BY_KEY_QUERY, {
138
+ containerId: client.getSiteAri(),
139
+ goalKey,
140
+ });
141
+ if (!result.success || !result.data) {
142
+ return { success: false, error: result.error ?? 'No data returned' };
143
+ }
144
+ const node = result.data.goals_byKey;
145
+ if (!node) {
146
+ return { success: false, error: `Goal ${goalKey} not found` };
147
+ }
148
+ const goal = {
149
+ id: node.id,
150
+ name: node.name,
151
+ key: node.key,
152
+ url: node.url,
153
+ state: node.state,
154
+ owner: node.owner,
155
+ parentGoal: node.parentGoal,
156
+ description: extractAdfText(node.description),
157
+ subGoals: (node.subGoals?.edges ?? []).map(e => ({
158
+ id: e.node.id,
159
+ name: e.node.name,
160
+ key: e.node.key,
161
+ url: e.node.url,
162
+ state: e.node.state,
163
+ owner: e.node.owner,
164
+ parentGoal: e.node.parentGoal,
165
+ description: extractAdfText(e.node.description),
166
+ })),
167
+ projects: (node.projects?.edges ?? []).map(e => e.node),
168
+ workItems: (node.workItems?.edges ?? [])
169
+ .filter(e => e.node.key)
170
+ .map(e => ({
171
+ key: e.node.key,
172
+ summary: e.node.summary ?? '',
173
+ status: e.node.status ?? { name: 'Unknown' },
174
+ issueType: e.node.issueType ?? { name: 'Unknown' },
175
+ })),
176
+ };
177
+ return { success: true, goal };
178
+ }
179
+ /**
180
+ * Resolve all Jira issue keys linked to a goal and optionally its sub-goals.
181
+ * Returns keys suitable for a `key in (...)` JQL query.
182
+ */
183
+ export async function resolveGoalWorkItems(client, goalKey, includeSubGoals = true) {
184
+ const result = await getGoalByKey(client, goalKey);
185
+ if (!result.success || !result.goal) {
186
+ return { success: false, error: result.error };
187
+ }
188
+ const goal = result.goal;
189
+ const issueKeys = new Set(goal.workItems.map(w => w.key));
190
+ if (includeSubGoals && goal.subGoals.length > 0) {
191
+ // Fetch each sub-goal's work items
192
+ const subResults = await Promise.all(goal.subGoals.map(sg => getGoalByKey(client, sg.key)));
193
+ for (const sub of subResults) {
194
+ if (sub.success && sub.goal) {
195
+ for (const w of sub.goal.workItems) {
196
+ issueKeys.add(w.key);
197
+ }
198
+ }
199
+ }
200
+ }
201
+ return { success: true, issueKeys: [...issueKeys], goal };
202
+ }
203
+ // --- Mutations ---
204
+ const GOAL_TYPES_QUERY = `
205
+ query GoalTypes($containerId: ID!) {
206
+ goals_goalTypes(containerId: $containerId) {
207
+ edges { node { id } }
208
+ }
209
+ }
210
+ `;
211
+ // Session-level cache for goal type ARIs
212
+ let cachedGoalTypeIds = null;
213
+ async function resolveGoalTypes(client) {
214
+ if (cachedGoalTypeIds)
215
+ return cachedGoalTypeIds;
216
+ const result = await client.queryTenanted(GOAL_TYPES_QUERY, { containerId: client.getSiteAri() });
217
+ if (!result.success || !result.data)
218
+ return [];
219
+ cachedGoalTypeIds = result.data.goals_goalTypes.edges.map(e => e.node.id);
220
+ return cachedGoalTypeIds;
221
+ }
222
+ /** Resolve goal type: first type for top-level goals, last type for sub-goals */
223
+ async function resolveGoalType(client, hasParent) {
224
+ const types = await resolveGoalTypes(client);
225
+ if (types.length === 0)
226
+ return null;
227
+ return hasParent ? types[types.length - 1] : types[0];
228
+ }
229
+ const GOAL_CREATE_MUTATION = `
230
+ mutation CreateGoal($input: TownsquareGoalsCreateInput!) {
231
+ goals_create(input: $input) {
232
+ success
233
+ errors { message }
234
+ goal { id name key url state { value label } }
235
+ }
236
+ }
237
+ `;
238
+ const GOAL_EDIT_MUTATION = `
239
+ mutation EditGoal($input: TownsquareGoalsEditInput!) {
240
+ goals_edit(input: $input) {
241
+ goal { id name key url state { value label } isArchived }
242
+ }
243
+ }
244
+ `;
245
+ const GOAL_CREATE_UPDATE_MUTATION = `
246
+ mutation CreateGoalUpdate($input: TownsquareGoalsCreateUpdateInput!) {
247
+ goals_createUpdate(input: $input) {
248
+ success
249
+ errors { message }
250
+ update { id url creationDate }
251
+ }
252
+ }
253
+ `;
254
+ const GOAL_LINK_WORK_ITEM_MUTATION = `
255
+ mutation LinkWorkItem($input: TownsquareGoalsLinkWorkItemInput!) {
256
+ goals_linkWorkItem(input: $input) {
257
+ goal { id name key }
258
+ }
259
+ }
260
+ `;
261
+ const GOAL_UNLINK_WORK_ITEM_MUTATION = `
262
+ mutation UnlinkWorkItem($input: TownsquareGoalsUnlinkWorkItemInput!) {
263
+ goals_unlinkWorkItem(input: $input) {
264
+ goal { id name key }
265
+ }
266
+ }
267
+ `;
268
+ /** Resolve a goal key to its ARI (needed for mutations) */
269
+ async function resolveGoalId(client, goalKey) {
270
+ const result = await getGoalByKey(client, goalKey);
271
+ if (!result.success || !result.goal) {
272
+ return { success: false, error: result.error ?? `Goal ${goalKey} not found` };
273
+ }
274
+ return { success: true, goalId: result.goal.id };
275
+ }
276
+ export async function createGoal(client, opts) {
277
+ const hasParent = !!opts.parentGoalKey;
278
+ const goalTypeId = await resolveGoalType(client, hasParent);
279
+ if (!goalTypeId) {
280
+ return { success: false, error: 'Could not discover goal types for this instance. Goals may not be enabled.' };
281
+ }
282
+ const input = {
283
+ containerId: client.getSiteAri(),
284
+ name: opts.name,
285
+ goalTypeId,
286
+ };
287
+ if (opts.parentGoalKey) {
288
+ const parent = await resolveGoalId(client, opts.parentGoalKey);
289
+ if (!parent.success)
290
+ return { success: false, error: `Parent goal: ${parent.error}` };
291
+ input.parentGoalId = parent.goalId;
292
+ }
293
+ if (opts.targetDate) {
294
+ input.targetDate = { date: opts.targetDate, confidence: 'QUARTER' };
295
+ }
296
+ if (opts.description) {
297
+ input.description = toAdf(opts.description);
298
+ }
299
+ const result = await client.queryTenanted(GOAL_CREATE_MUTATION, { input });
300
+ if (!result.success || !result.data) {
301
+ return { success: false, error: result.error ?? 'Create failed' };
302
+ }
303
+ const mutation = result.data.goals_create;
304
+ if (!mutation.success || !mutation.goal) {
305
+ const msg = mutation.errors?.map(e => e.message).join('; ') ?? 'Unknown error';
306
+ return { success: false, error: msg };
307
+ }
308
+ return { success: true, goal: { name: mutation.goal.name, key: mutation.goal.key, url: mutation.goal.url } };
309
+ }
310
+ export async function editGoal(client, goalKey, opts) {
311
+ const resolved = await resolveGoalId(client, goalKey);
312
+ if (!resolved.success)
313
+ return { success: false, error: resolved.error };
314
+ const input = { goalId: resolved.goalId };
315
+ if (opts.name !== undefined)
316
+ input.name = opts.name;
317
+ if (opts.description !== undefined) {
318
+ input.description = toAdf(opts.description);
319
+ }
320
+ if (opts.targetDate !== undefined) {
321
+ input.targetDate = { date: opts.targetDate, confidence: 'QUARTER' };
322
+ }
323
+ if (opts.startDate !== undefined)
324
+ input.startDate = opts.startDate;
325
+ if (opts.archived !== undefined)
326
+ input.archived = opts.archived;
327
+ const result = await client.queryTenanted(GOAL_EDIT_MUTATION, { input });
328
+ if (!result.success)
329
+ return { success: false, error: result.error };
330
+ return { success: true };
331
+ }
332
+ export async function createGoalStatusUpdate(client, goalKey, status, summary) {
333
+ const resolved = await resolveGoalId(client, goalKey);
334
+ if (!resolved.success)
335
+ return { success: false, error: resolved.error };
336
+ const input = {
337
+ goalId: resolved.goalId,
338
+ status,
339
+ };
340
+ if (summary) {
341
+ input.summary = toAdf(summary);
342
+ }
343
+ const result = await client.queryTenanted(GOAL_CREATE_UPDATE_MUTATION, { input });
344
+ if (!result.success || !result.data) {
345
+ return { success: false, error: result.error ?? 'Status update failed' };
346
+ }
347
+ const mutation = result.data.goals_createUpdate;
348
+ if (!mutation.success) {
349
+ const msg = mutation.errors?.map(e => e.message).join('; ') ?? 'Status update failed (no error details)';
350
+ return { success: false, error: msg };
351
+ }
352
+ return { success: true };
353
+ }
354
+ const ISSUE_BY_KEY_QUERY = `
355
+ query IssueByKey($cloudId: ID!, $key: String!) {
356
+ jira { issueByKey(key: $key, cloudId: $cloudId) { id } }
357
+ }
358
+ `;
359
+ /** Resolve a Jira issue key to its ARI (needed for goal work item linking) */
360
+ async function resolveIssueAri(client, issueKey) {
361
+ const result = await client.queryTenanted(ISSUE_BY_KEY_QUERY, { key: issueKey });
362
+ if (!result.success || !result.data?.jira?.issueByKey) {
363
+ return { success: false, error: `Issue ${issueKey} not found` };
364
+ }
365
+ return { success: true, issueAri: result.data.jira.issueByKey.id };
366
+ }
367
+ export async function linkWorkItem(client, goalKey, issueKey) {
368
+ const [resolved, issue] = await Promise.all([
369
+ resolveGoalId(client, goalKey),
370
+ resolveIssueAri(client, issueKey),
371
+ ]);
372
+ if (!resolved.success)
373
+ return { success: false, error: resolved.error };
374
+ if (!issue.success)
375
+ return { success: false, error: issue.error };
376
+ const result = await client.queryTenanted(GOAL_LINK_WORK_ITEM_MUTATION, {
377
+ input: { goalId: resolved.goalId, workItemId: issue.issueAri },
378
+ });
379
+ if (!result.success)
380
+ return { success: false, error: result.error };
381
+ return { success: true };
382
+ }
383
+ export async function unlinkWorkItem(client, goalKey, issueKey) {
384
+ const [resolved, issue] = await Promise.all([
385
+ resolveGoalId(client, goalKey),
386
+ resolveIssueAri(client, issueKey),
387
+ ]);
388
+ if (!resolved.success)
389
+ return { success: false, error: resolved.error };
390
+ if (!issue.success)
391
+ return { success: false, error: issue.error };
392
+ const result = await client.queryTenanted(GOAL_UNLINK_WORK_ITEM_MUTATION, {
393
+ input: { goalId: resolved.goalId, workItemId: issue.issueAri },
394
+ });
395
+ if (!result.success)
396
+ return { success: false, error: result.error };
397
+ return { success: true };
398
+ }
@@ -1131,6 +1131,47 @@ export class JiraClient {
1131
1131
  jql: result.jql || '',
1132
1132
  };
1133
1133
  }
1134
+ // ── Attachment Operations ────────────────────────────────
1135
+ async getAttachmentInfo(attachmentId) {
1136
+ const meta = await this.client.issueAttachments.getAttachment(attachmentId);
1137
+ return {
1138
+ id: meta.id?.toString() ?? attachmentId,
1139
+ filename: meta.filename ?? 'unnamed',
1140
+ mimeType: meta.mimeType ?? 'application/octet-stream',
1141
+ size: meta.size ?? 0,
1142
+ created: meta.created ?? '',
1143
+ author: meta.author?.displayName ?? 'Unknown',
1144
+ url: meta.content ?? '',
1145
+ };
1146
+ }
1147
+ async downloadAttachment(attachmentId) {
1148
+ const content = await this.client.issueAttachments.getAttachmentContent(attachmentId);
1149
+ // jira.js generic defaults to Buffer; ensure we return Buffer even if runtime type differs
1150
+ return Buffer.isBuffer(content) ? content : Buffer.from(content);
1151
+ }
1152
+ async uploadAttachment(issueKey, filename, content, mimeType) {
1153
+ const result = await this.client.issueAttachments.addAttachment({
1154
+ issueIdOrKey: issueKey,
1155
+ attachment: {
1156
+ filename,
1157
+ file: content,
1158
+ mimeType,
1159
+ },
1160
+ });
1161
+ const att = Array.isArray(result) ? result[0] : result;
1162
+ return {
1163
+ id: att.id?.toString() ?? '',
1164
+ filename: att.filename ?? filename,
1165
+ mimeType: att.mimeType ?? mimeType,
1166
+ size: att.size ?? content.length,
1167
+ created: att.created ?? new Date().toISOString(),
1168
+ author: att.author?.displayName ?? 'Unknown',
1169
+ url: att.content ?? '',
1170
+ };
1171
+ }
1172
+ async deleteAttachment(attachmentId) {
1173
+ await this.client.issueAttachments.removeAttachment(attachmentId);
1174
+ }
1134
1175
  async deleteFilter(filterId) {
1135
1176
  await this.client.filters.deleteFilter(filterId);
1136
1177
  }
@@ -742,7 +742,7 @@ async function renderHierarchy(issues, graphqlClient) {
742
742
  }
743
743
  }
744
744
  if (parentKeys.size > MAX_HIERARCHY_ROOTS) {
745
- lines.push('', `*Showing ${MAX_HIERARCHY_ROOTS} of ${parentKeys.size} root issues. Use analyze_jira_plan for a focused subtree.*`);
745
+ lines.push('', `*Showing ${MAX_HIERARCHY_ROOTS} of ${parentKeys.size} root issues. Use manage_jira_plan for a focused subtree.*`);
746
746
  }
747
747
  return lines.join('\n');
748
748
  }
@@ -858,11 +858,11 @@ export async function handleAnalysisRequest(jiraClient, request, graphqlClient,
858
858
  const dataRef = args.dataRef;
859
859
  if (dataRef && typeof dataRef === 'string' && dataRef.trim() !== '') {
860
860
  if (!cache) {
861
- throw new McpError(ErrorCode.InvalidParams, 'dataRef requires the graph object cache (start a walk with analyze_jira_plan first).');
861
+ throw new McpError(ErrorCode.InvalidParams, 'dataRef requires the graph object cache (start a walk with manage_jira_plan first).');
862
862
  }
863
863
  const cached = cache.get(dataRef);
864
864
  if (!cached) {
865
- throw new McpError(ErrorCode.InvalidParams, `No cached walk for "${dataRef}". Start one with analyze_jira_plan first.`);
865
+ throw new McpError(ErrorCode.InvalidParams, `No cached walk for "${dataRef}". Start one with manage_jira_plan first.`);
866
866
  }
867
867
  if (cached.state === 'walking') {
868
868
  return {
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Handler for manage_jira_media tool.
3
+ * See ADR-211: Attachment and Workspace Management.
4
+ */
5
+ import * as fs from 'node:fs/promises';
6
+ import { mediaNextSteps } from '../utils/next-steps.js';
7
+ import { ensureWorkspaceDir, formatSize, resolveWorkspacePath, ensureParentDir, verifyPathSafety, sanitizeFilename, } from '../workspace/index.js';
8
+ export async function handleMediaRequest(client, args) {
9
+ switch (args.operation) {
10
+ case 'list': {
11
+ if (!args.issueKey) {
12
+ return { content: [{ type: 'text', text: 'issueKey is required for list operation' }], isError: true };
13
+ }
14
+ const attachments = await client.getIssueAttachments(args.issueKey);
15
+ if (attachments.length === 0) {
16
+ let text = `No attachments on ${args.issueKey}.`;
17
+ text += mediaNextSteps('list', { issueKey: args.issueKey });
18
+ return { content: [{ type: 'text', text }] };
19
+ }
20
+ const lines = attachments.map(a => `- ${a.filename} | ${a.mimeType} | ${formatSize(a.size)} | id:${a.id} | ${a.author} | ${a.created}`);
21
+ let text = `Attachments on ${args.issueKey} (${attachments.length}):\n${lines.join('\n')}`;
22
+ text += mediaNextSteps('list', { issueKey: args.issueKey });
23
+ return { content: [{ type: 'text', text }] };
24
+ }
25
+ case 'upload': {
26
+ if (!args.issueKey || !args.filename || !args.mediaType) {
27
+ return {
28
+ content: [{ type: 'text', text: 'issueKey, filename, and mediaType are required for upload' }],
29
+ isError: true,
30
+ };
31
+ }
32
+ let buffer;
33
+ if (args.workspaceFile) {
34
+ const filePath = resolveWorkspacePath(args.workspaceFile);
35
+ await verifyPathSafety(filePath);
36
+ try {
37
+ buffer = await fs.readFile(filePath);
38
+ }
39
+ catch {
40
+ return { content: [{ type: 'text', text: `Workspace file not found: ${args.workspaceFile}` }], isError: true };
41
+ }
42
+ }
43
+ else if (args.content) {
44
+ buffer = Buffer.from(args.content, 'base64');
45
+ }
46
+ else {
47
+ return {
48
+ content: [{ type: 'text', text: 'Either content (base64) or workspaceFile is required for upload' }],
49
+ isError: true,
50
+ };
51
+ }
52
+ const safeFilename = sanitizeFilename(args.filename);
53
+ const attachment = await client.uploadAttachment(args.issueKey, safeFilename, buffer, args.mediaType);
54
+ let text = `Uploaded: ${attachment.filename} | ${attachment.mimeType} | ${formatSize(attachment.size)} | id:${attachment.id}`;
55
+ text += mediaNextSteps('upload', { issueKey: args.issueKey });
56
+ return { content: [{ type: 'text', text }] };
57
+ }
58
+ case 'delete': {
59
+ if (!args.attachmentId) {
60
+ return { content: [{ type: 'text', text: 'attachmentId is required for delete operation' }], isError: true };
61
+ }
62
+ await client.deleteAttachment(args.attachmentId);
63
+ return { content: [{ type: 'text', text: `Permanently deleted attachment ${args.attachmentId} from Jira. This cannot be undone.` }] };
64
+ }
65
+ case 'view': {
66
+ if (!args.attachmentId) {
67
+ return { content: [{ type: 'text', text: 'attachmentId is required for view operation' }], isError: true };
68
+ }
69
+ const info = await client.getAttachmentInfo(args.attachmentId);
70
+ if (!info.mimeType.startsWith('image/')) {
71
+ return {
72
+ content: [{
73
+ type: 'text',
74
+ text: `${info.filename} | ${info.mimeType} | ${formatSize(info.size)}\n\nNot an image — cannot display inline. Use download to fetch raw content.`,
75
+ }],
76
+ };
77
+ }
78
+ const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
79
+ if (info.size > MAX_IMAGE_SIZE) {
80
+ return {
81
+ content: [{
82
+ type: 'text',
83
+ text: `${info.filename} | ${info.mimeType} | ${formatSize(info.size)}\n\nImage too large to display inline (${(info.size / 1024 / 1024).toFixed(1)}MB, max 5MB). Use download instead.`,
84
+ }],
85
+ };
86
+ }
87
+ const bytes = await client.downloadAttachment(args.attachmentId);
88
+ return {
89
+ content: [
90
+ { type: 'text', text: `${info.filename} | ${info.mimeType}` },
91
+ { type: 'image', data: bytes.toString('base64'), mimeType: info.mimeType },
92
+ ],
93
+ };
94
+ }
95
+ case 'get_info': {
96
+ if (!args.attachmentId) {
97
+ return { content: [{ type: 'text', text: 'attachmentId is required for get_info operation' }], isError: true };
98
+ }
99
+ const attachInfo = await client.getAttachmentInfo(args.attachmentId);
100
+ return {
101
+ content: [{
102
+ type: 'text',
103
+ text: `${attachInfo.filename} | ${attachInfo.mimeType} | ${formatSize(attachInfo.size)} | id:${attachInfo.id} | ${attachInfo.author} | ${attachInfo.created}`,
104
+ }],
105
+ };
106
+ }
107
+ case 'download': {
108
+ if (!args.attachmentId) {
109
+ return { content: [{ type: 'text', text: 'attachmentId is required for download operation' }], isError: true };
110
+ }
111
+ const dlInfo = await client.getAttachmentInfo(args.attachmentId);
112
+ const dlBytes = await client.downloadAttachment(args.attachmentId);
113
+ const status = await ensureWorkspaceDir();
114
+ if (!status.valid) {
115
+ return { content: [{ type: 'text', text: `Workspace invalid: ${status.warning}` }], isError: true };
116
+ }
117
+ const dlFilename = args.filename || sanitizeFilename(dlInfo.filename);
118
+ const dlPath = resolveWorkspacePath(dlFilename);
119
+ await verifyPathSafety(dlPath);
120
+ await ensureParentDir(dlPath);
121
+ await fs.writeFile(dlPath, dlBytes);
122
+ let text = `Downloaded: ${dlFilename} | ${dlInfo.mimeType} | ${formatSize(dlBytes.length)}\nPath: ${dlPath}`;
123
+ text += `\n\nUse manage_workspace read or manage_jira_media upload with workspaceFile:"${dlFilename}" to use it.`;
124
+ text += mediaNextSteps('download', {});
125
+ return { content: [{ type: 'text', text }] };
126
+ }
127
+ default:
128
+ return { content: [{ type: 'text', text: `Unknown media operation: ${args.operation}` }], isError: true };
129
+ }
130
+ }