@aaronsb/jira-cloud-mcp 0.9.0 → 0.11.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.
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Handler for manage_workspace tool.
3
+ * See ADR-211: Attachment and Workspace Management.
4
+ */
5
+ import * as fs from 'node:fs/promises';
6
+ import * as path from 'node:path';
7
+ import { ensureWorkspaceDir, formatSize, resolveWorkspacePath, ensureParentDir, verifyPathSafety, } from '../workspace/index.js';
8
+ const TEXT_INLINE_LIMIT = 100 * 1024; // 100KB
9
+ const IMAGE_INLINE_LIMIT = 5 * 1024 * 1024; // 5MB
10
+ const IMAGE_EXTENSIONS = {
11
+ '.png': 'image/png',
12
+ '.jpg': 'image/jpeg',
13
+ '.jpeg': 'image/jpeg',
14
+ '.gif': 'image/gif',
15
+ '.webp': 'image/webp',
16
+ '.svg': 'image/svg+xml',
17
+ '.bmp': 'image/bmp',
18
+ '.ico': 'image/x-icon',
19
+ };
20
+ const TEXT_EXTENSIONS = new Set([
21
+ '.txt', '.md', '.json', '.xml', '.csv', '.html', '.htm',
22
+ '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.log',
23
+ '.js', '.ts', '.py', '.rb', '.sh', '.bash', '.zsh',
24
+ '.css', '.scss', '.less', '.svg',
25
+ ]);
26
+ export async function handleWorkspaceRequest(args) {
27
+ switch (args.operation) {
28
+ case 'list':
29
+ return handleList();
30
+ case 'read':
31
+ return handleRead(args);
32
+ case 'write':
33
+ return handleWrite(args);
34
+ case 'delete':
35
+ return handleDelete(args);
36
+ case 'mkdir':
37
+ return handleMkdir(args);
38
+ case 'move':
39
+ return handleMove(args);
40
+ default:
41
+ return { content: [{ type: 'text', text: `Unknown workspace operation: ${args.operation}` }], isError: true };
42
+ }
43
+ }
44
+ async function handleList() {
45
+ const status = await ensureWorkspaceDir();
46
+ if (!status.valid) {
47
+ return { content: [{ type: 'text', text: `Workspace invalid: ${status.warning}` }], isError: true };
48
+ }
49
+ const lines = [`Workspace: ${status.path}\n`];
50
+ await listRecursive(status.path, status.path, lines, 0);
51
+ if (lines.length === 1) {
52
+ return { content: [{ type: 'text', text: `Workspace: ${status.path}\n\n(empty — no files staged)` }] };
53
+ }
54
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
55
+ }
56
+ const MAX_LIST_DEPTH = 10;
57
+ async function listRecursive(rootDir, dir, lines, depth) {
58
+ if (depth >= MAX_LIST_DEPTH) {
59
+ lines.push(`${' '.repeat(depth + 1)}(truncated — max depth ${MAX_LIST_DEPTH})`);
60
+ return;
61
+ }
62
+ let entries;
63
+ try {
64
+ entries = await fs.readdir(dir, { withFileTypes: true });
65
+ }
66
+ catch {
67
+ return;
68
+ }
69
+ const indent = ' '.repeat(depth + 1);
70
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
71
+ if (entry.isSymbolicLink())
72
+ continue; // skip symlinks to prevent loops
73
+ try {
74
+ const fullPath = path.join(dir, entry.name);
75
+ if (entry.isDirectory()) {
76
+ lines.push(`${indent}${entry.name}/`);
77
+ await listRecursive(rootDir, fullPath, lines, depth + 1);
78
+ }
79
+ else if (entry.isFile()) {
80
+ const stat = await fs.stat(fullPath);
81
+ lines.push(`${indent}${entry.name} (${formatSize(stat.size)}, ${stat.mtime.toISOString().slice(0, 16)})`);
82
+ }
83
+ }
84
+ catch {
85
+ // Skip entries we can't stat
86
+ }
87
+ }
88
+ }
89
+ async function handleRead(args) {
90
+ if (!args.filename) {
91
+ return { content: [{ type: 'text', text: 'filename is required for read operation' }], isError: true };
92
+ }
93
+ const filePath = resolveWorkspacePath(args.filename);
94
+ await verifyPathSafety(filePath);
95
+ let stat;
96
+ try {
97
+ stat = await fs.stat(filePath);
98
+ }
99
+ catch {
100
+ return { content: [{ type: 'text', text: `File not found in workspace: ${args.filename}` }], isError: true };
101
+ }
102
+ const ext = path.extname(args.filename).toLowerCase();
103
+ const isText = TEXT_EXTENSIONS.has(ext);
104
+ const imageMime = IMAGE_EXTENSIONS[ext];
105
+ // Inline text
106
+ if (isText && stat.size <= TEXT_INLINE_LIMIT) {
107
+ const content = await fs.readFile(filePath, 'utf-8');
108
+ return { content: [{ type: 'text', text: `File: ${args.filename} (${formatSize(stat.size)})\nPath: ${filePath}\n\n${content}` }] };
109
+ }
110
+ // Inline image
111
+ if (imageMime && stat.size <= IMAGE_INLINE_LIMIT) {
112
+ const bytes = await fs.readFile(filePath);
113
+ return {
114
+ content: [
115
+ { type: 'text', text: `File: ${args.filename} | ${formatSize(stat.size)}\nPath: ${filePath}` },
116
+ { type: 'image', data: bytes.toString('base64'), mimeType: imageMime },
117
+ ],
118
+ };
119
+ }
120
+ // Too large or unsupported — path reference only
121
+ const label = imageMime ? 'image (too large to display inline)' : isText ? 'text' : 'binary';
122
+ return {
123
+ content: [{
124
+ type: 'text',
125
+ text: `File: ${args.filename} | ${formatSize(stat.size)} | ${label}\nPath: ${filePath}\n\nUse manage_jira_media upload with workspaceFile to upload, or manage_workspace delete to remove.`,
126
+ }],
127
+ };
128
+ }
129
+ async function handleWrite(args) {
130
+ if (!args.filename) {
131
+ return { content: [{ type: 'text', text: 'filename is required for write operation' }], isError: true };
132
+ }
133
+ if (!args.content) {
134
+ return { content: [{ type: 'text', text: 'content (base64-encoded) is required for write operation' }], isError: true };
135
+ }
136
+ const status = await ensureWorkspaceDir();
137
+ if (!status.valid) {
138
+ return { content: [{ type: 'text', text: `Workspace invalid: ${status.warning}` }], isError: true };
139
+ }
140
+ const filePath = resolveWorkspacePath(args.filename);
141
+ await verifyPathSafety(filePath);
142
+ await ensureParentDir(filePath);
143
+ const buffer = Buffer.from(args.content, 'base64');
144
+ await fs.writeFile(filePath, buffer);
145
+ return {
146
+ content: [{
147
+ type: 'text',
148
+ text: `Written: ${args.filename} (${formatSize(buffer.length)})\nPath: ${filePath}`,
149
+ }],
150
+ };
151
+ }
152
+ async function handleDelete(args) {
153
+ if (!args.filename) {
154
+ return { content: [{ type: 'text', text: 'filename is required for delete operation' }], isError: true };
155
+ }
156
+ const filePath = resolveWorkspacePath(args.filename);
157
+ await verifyPathSafety(filePath);
158
+ try {
159
+ const stat = await fs.stat(filePath);
160
+ if (stat.isDirectory()) {
161
+ await fs.rm(filePath, { recursive: true });
162
+ return { content: [{ type: 'text', text: `Deleted local directory: ${args.filename} (Jira attachments unaffected)` }] };
163
+ }
164
+ await fs.unlink(filePath);
165
+ }
166
+ catch {
167
+ return { content: [{ type: 'text', text: `File not found in workspace: ${args.filename}` }], isError: true };
168
+ }
169
+ return { content: [{ type: 'text', text: `Deleted local file: ${args.filename} (Jira attachments unaffected)` }] };
170
+ }
171
+ async function handleMkdir(args) {
172
+ if (!args.filename) {
173
+ return { content: [{ type: 'text', text: 'filename (directory path) is required for mkdir operation' }], isError: true };
174
+ }
175
+ const status = await ensureWorkspaceDir();
176
+ if (!status.valid) {
177
+ return { content: [{ type: 'text', text: `Workspace invalid: ${status.warning}` }], isError: true };
178
+ }
179
+ const dirPath = resolveWorkspacePath(args.filename);
180
+ await verifyPathSafety(dirPath);
181
+ await fs.mkdir(dirPath, { recursive: true, mode: 0o755 });
182
+ return {
183
+ content: [{
184
+ type: 'text',
185
+ text: `Created: ${args.filename}/\nPath: ${dirPath}`,
186
+ }],
187
+ };
188
+ }
189
+ async function handleMove(args) {
190
+ if (!args.filename) {
191
+ return { content: [{ type: 'text', text: 'filename (source path) is required for move operation' }], isError: true };
192
+ }
193
+ if (!args.destination) {
194
+ return { content: [{ type: 'text', text: 'destination path is required for move operation' }], isError: true };
195
+ }
196
+ const srcPath = resolveWorkspacePath(args.filename);
197
+ await verifyPathSafety(srcPath);
198
+ const destPath = resolveWorkspacePath(args.destination);
199
+ await verifyPathSafety(destPath);
200
+ try {
201
+ await fs.stat(srcPath);
202
+ }
203
+ catch {
204
+ return { content: [{ type: 'text', text: `Source not found in workspace: ${args.filename}` }], isError: true };
205
+ }
206
+ await ensureParentDir(destPath);
207
+ await fs.rename(srcPath, destPath);
208
+ return {
209
+ content: [{
210
+ type: 'text',
211
+ text: `Moved: ${args.filename} -> ${args.destination}\nPath: ${destPath}`,
212
+ }],
213
+ };
214
+ }
package/build/index.js CHANGED
@@ -11,14 +11,18 @@ 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 { classifyFieldErrors } from './utils/field-error-classification.js';
25
+ import { normalizeArgs } from './utils/normalize-args.js';
22
26
  // Jira credentials from environment variables
23
27
  const JIRA_EMAIL = process.env.JIRA_EMAIL;
24
28
  const JIRA_API_TOKEN = process.env.JIRA_API_TOKEN;
@@ -160,6 +164,8 @@ class JiraServer {
160
164
  manage_jira_sprint: handleSprintRequest,
161
165
  manage_jira_filter: handleFilterRequest,
162
166
  analyze_jira_issues: (client, req) => handleAnalysisRequest(client, req, this.graphqlClient, this.cache),
167
+ manage_jira_media: (client, req) => handleMediaRequest(client, normalizeArgs(req.params.arguments ?? {})),
168
+ manage_workspace: (_client, req) => handleWorkspaceRequest(normalizeArgs(req.params.arguments ?? {})),
163
169
  };
164
170
  const handlers = {
165
171
  ...toolHandlers,
@@ -243,6 +249,11 @@ class JiraServer {
243
249
  for (const [field, msg] of Object.entries(fieldErrors)) {
244
250
  lines.push(`- \`${field}\`: ${msg}`);
245
251
  }
252
+ // Distinguish the failure modes Jira's message conflates (ADR-213 §A5, #49 / #52c).
253
+ const guidance = classifyFieldErrors(fieldErrors);
254
+ if (guidance.length > 0) {
255
+ lines.push('', '**What to do:**', ...guidance);
256
+ }
246
257
  }
247
258
  // On create failures, invalidate cache and append required fields guidance
248
259
  const reqArgs = request.params.arguments;
@@ -89,13 +89,10 @@ function stripHtml(html) {
89
89
  .replace(/\s+/g, ' ')
90
90
  .trim();
91
91
  }
92
- // ============================================================================
93
- // Issue Rendering
94
- // ============================================================================
95
92
  /**
96
93
  * Render a single issue as markdown
97
94
  */
98
- export function renderIssue(issue, transitions) {
95
+ export function renderIssue(issue, transitions, opts = {}) {
99
96
  const lines = [];
100
97
  lines.push(`# ${issue.key}: ${issue.summary}`);
101
98
  lines.push('');
@@ -170,17 +167,33 @@ export function renderIssue(issue, transitions) {
170
167
  lines.push(`[${startIdx + i}/${issue.comments.length}] ${comment.author} (${formatDate(comment.created)}): ${truncate(preview, 200)}`);
171
168
  }
172
169
  }
173
- // Custom fields (from catalog discovery)
174
- if (issue.customFieldValues && issue.customFieldValues.length > 0) {
170
+ // Custom fields — progressive reveal (ADR-214). Default is a breadcrumb pointing at the
171
+ // opt-in expand and the scoped resource; the full dump is gated behind `customFields: 'dump'`.
172
+ // Zero populated → silent in every mode.
173
+ const customFieldsMode = opts.customFields ?? 'breadcrumb';
174
+ const cfs = issue.customFieldValues ?? [];
175
+ const populatedCount = cfs.length;
176
+ if (customFieldsMode === 'dump' && populatedCount > 0) {
175
177
  lines.push('');
176
178
  lines.push('Custom Fields:');
177
- for (const cf of issue.customFieldValues) {
179
+ for (const cf of cfs) {
178
180
  const displayValue = Array.isArray(cf.value)
179
181
  ? cf.value.join(', ')
180
182
  : String(cf.value);
181
183
  lines.push(`${cf.name} (${cf.type}): ${displayValue}`);
182
184
  }
183
185
  }
186
+ else if (customFieldsMode === 'breadcrumb' && populatedCount > 0) {
187
+ lines.push('');
188
+ // Issue-type names can contain spaces ("User Story", "Service Request") — the catalog
189
+ // resource emitter encodes the type and the resolver decodes it (resource-handlers.ts:148, 533),
190
+ // so the breadcrumb URI has to encode too or the link round-trips wrong.
191
+ const uri = opts.projectKey && opts.issueTypeName
192
+ ? ` For what's settable on this issue type: read \`jira://custom-fields/${opts.projectKey}/${encodeURIComponent(opts.issueTypeName)}\`.`
193
+ : '';
194
+ lines.push(`📋 ${populatedCount} populated custom field${populatedCount === 1 ? '' : 's'} not shown. ` +
195
+ `To view: \`expand: ["custom_fields"]\`.${uri}`);
196
+ }
184
197
  // Status history (if requested via expand: ["history"])
185
198
  if (issue.statusHistory && issue.statusHistory.length > 0) {
186
199
  lines.push('');
@@ -142,7 +142,7 @@ export const toolSchemas = {
142
142
  },
143
143
  manage_jira_issue: {
144
144
  name: 'manage_jira_issue',
145
- description: 'Get, create, update, delete, move, transition, comment on, link, log work on, or explore hierarchy of Jira issues',
145
+ description: 'Get, create, update, delete, move, transition, comment on, link, log work on, or explore hierarchy of Jira issues. For custom-field writes, consult `jira://capabilities` (field routing) and `jira://custom-fields/{projectKey}/{issueType}` (what is settable here) — some fields need dedicated tools and others auto-resolve names to ids.',
146
146
  inputSchema: {
147
147
  type: 'object',
148
148
  properties: {
@@ -186,7 +186,7 @@ export const toolSchemas = {
186
186
  },
187
187
  customFields: {
188
188
  type: 'object',
189
- description: 'Custom field values as key-value pairs.',
189
+ description: 'Custom field values as { name | id : value }. Names auto-resolve to customfield_NNNNN. Read `jira://capabilities` for the field-routing table (which fields need a dedicated tool or have value-resolution) and `jira://custom-fields/{projectKey}/{issueType}` for what is settable on a given screen, with types and clearing semantics.',
190
190
  },
191
191
  dueDate: {
192
192
  type: ['string', 'null'],
@@ -265,9 +265,9 @@ export const toolSchemas = {
265
265
  type: 'array',
266
266
  items: {
267
267
  type: 'string',
268
- enum: ['comments', 'transitions', 'attachments', 'related_issues', 'history'],
268
+ enum: ['comments', 'transitions', 'attachments', 'related_issues', 'history', 'custom_fields'],
269
269
  },
270
- description: 'Additional fields to include in the response.',
270
+ description: 'Additional fields to include in the response. Populated custom fields are NOT shown by default — `get` emits a one-line breadcrumb with the count and the opt-in. Pass "custom_fields" to render the full populated dump. For what is *settable* on this issue type (with usage hints), read the scoped resource `jira://custom-fields/{projectKey}/{issueType}` instead.',
271
271
  },
272
272
  },
273
273
  required: ['operation'],
@@ -490,6 +490,72 @@ export const toolSchemas = {
490
490
  required: [],
491
491
  },
492
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'],
557
+ },
558
+ },
493
559
  queue_jira_operations: {
494
560
  name: 'queue_jira_operations',
495
561
  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.',
@@ -503,7 +569,7 @@ export const toolSchemas = {
503
569
  properties: {
504
570
  tool: {
505
571
  type: 'string',
506
- 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'],
507
573
  description: 'Which tool to call.',
508
574
  },
509
575
  args: {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Field-rejection error classification (ADR-213 §A5, issues #49 and #52c).
3
+ *
4
+ * Jira rejects a `customFields` write with one opaque message —
5
+ * "Field 'X' cannot be set. It is not on the appropriate screen, or unknown."
6
+ * — that conflates several failure modes, each needing a different recovery. Given Jira's
7
+ * field-error map (keyed by field id or name), this returns extra guidance lines that pull
8
+ * those modes apart:
9
+ *
10
+ * - field has a dedicated path (Sprint, Epic Link, Parent, Rank) → point at the right tool/param
11
+ * - field exists in the catalog but isn't writable here → "editable inline in the UI, or via the
12
+ * owning app — not reachable through the standard edit endpoint"
13
+ * - field isn't in the catalog → "not a field this instance exposes — see jira://custom-fields"
14
+ * - catalog unavailable → say so; the name may be right but can't be verified here
15
+ */
16
+ import { fieldDiscovery } from '../client/field-discovery.js';
17
+ import { routeForField } from '../extensions/index.js';
18
+ /** Looks like a Connect/Forge app field key (e.g. `io.tempo.jira__account`) rather than a
19
+ * `customfield_NNNNN` id or a plain field name — Jira sometimes reports field errors this way. */
20
+ function looksLikeAppFieldKey(key) {
21
+ return !key.startsWith('customfield_') && (key.includes('__') || /^[a-z][\w-]*(\.[\w-]+){2,}/i.test(key));
22
+ }
23
+ export function classifyFieldErrors(fieldErrors) {
24
+ const out = [];
25
+ const catalogState = fieldDiscovery.getState();
26
+ for (const fieldKey of Object.keys(fieldErrors)) {
27
+ const catalogEntry = fieldKey.startsWith('customfield_')
28
+ ? fieldDiscovery.getFieldById(fieldKey)
29
+ : undefined;
30
+ const humanName = catalogEntry?.name ?? fieldKey;
31
+ const resolvedId = fieldKey.startsWith('customfield_')
32
+ ? fieldKey
33
+ : fieldDiscovery.resolveNameToId(fieldKey);
34
+ const isKnownField = !!catalogEntry || !!resolvedId;
35
+ const route = routeForField(fieldKey) ?? routeForField(humanName);
36
+ if (route) {
37
+ out.push(` → \`${humanName}\`: ${route.unhandled.message}`);
38
+ continue;
39
+ }
40
+ if (isKnownField) {
41
+ out.push(` → \`${humanName}\` exists on this instance but the write was rejected — it may be off ` +
42
+ `the Edit screen for this issue type, hidden by a field configuration, or an app-managed ` +
43
+ `field that wants a different value format (e.g. a numeric id rather than a name). It may ` +
44
+ `still be editable inline in the Jira UI.`);
45
+ continue;
46
+ }
47
+ if (looksLikeAppFieldKey(fieldKey)) {
48
+ out.push(` → \`${fieldKey}\` is a field registered by a Connect/Forge app — the write was rejected ` +
49
+ `(see the message above). App-managed fields often expect a specific value format ` +
50
+ `(e.g. a numeric account/option id rather than a name) or must be set through the app's own ` +
51
+ `interface; setting it inline in the Jira UI usually works.`);
52
+ continue;
53
+ }
54
+ if (catalogState === 'unavailable') {
55
+ out.push(` → \`${fieldKey}\`: couldn't verify this field name — the custom-field catalog is ` +
56
+ `unavailable (the admin field API returned 403 and the basic fallback also failed). The ` +
57
+ `name may be correct but can't be checked here.`);
58
+ continue;
59
+ }
60
+ out.push(` → \`${fieldKey}\` isn't a custom field this instance exposes (checked the ${catalogState} ` +
61
+ `catalog). Read jira://custom-fields for available names, or ` +
62
+ `jira://custom-fields/{projectKey}/{issueType} for the fields on a specific issue type.`);
63
+ }
64
+ return out;
65
+ }
@@ -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' });
@@ -207,6 +207,21 @@ export function goalNextSteps(operation, goalKey, workItemCount) {
207
207
  }
208
208
  return steps.length > 0 ? formatSteps(steps) : '';
209
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
+ }
210
225
  export function analysisNextSteps(jql, issueKeys, truncated = false, groupBy, filterSource) {
211
226
  const steps = [];
212
227
  if (issueKeys.length > 0) {
@@ -0,0 +1 @@
1
+ export { checkWorkspaceStatus, dataDir, ensureParentDir, ensureWorkspaceDir, formatSize, getWorkspaceDir, resolveWorkspacePath, sanitizeFilename, sanitizePath, validateWorkspaceDir, verifyPathSafety, } from './workspace.js';