@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,32 @@
1
+ /**
2
+ * Extension modules (ADR-213 §B).
3
+ *
4
+ * An *extension module* bundles the handling for one thing that needs treatment beyond a plain
5
+ * `customFields` write — Atlassian's own special fields (Sprint, Epic Link, Rank), the Tempo
6
+ * Account field, and so on. A module declares its **bounds** (the field routes it owns — it touches
7
+ * those fields and nothing else) and, optionally, a cheap read-only `detect()` so jira://capabilities
8
+ * can report whether the extension's target is present on this tenant.
9
+ *
10
+ * This is deliberately *not* a generic plugin SDK: modules are composed by an explicit, hand-curated
11
+ * list in `index.ts` — no auto-discovery, no glob. Adding one is a reviewed edit, justified by a
12
+ * common user flow.
13
+ */
14
+ /**
15
+ * Match a human-friendly value against a field's allowed options. Exact (case-insensitive) wins;
16
+ * otherwise a single substring match; ambiguity / no match throws with the available options. Shared
17
+ * by modules that resolve a name to an option id.
18
+ */
19
+ export function matchAllowedValue(value, options, fieldLabel, scope) {
20
+ const norm = (s) => s.trim().toLowerCase();
21
+ const target = norm(value);
22
+ const exact = options.filter(o => norm(o.value) === target);
23
+ const matches = exact.length > 0 ? exact : options.filter(o => norm(o.value).includes(target));
24
+ if (matches.length === 1)
25
+ return matches[0].id;
26
+ const list = options.map(o => `${o.value} (${o.id})`).join(', ');
27
+ if (matches.length === 0) {
28
+ throw new Error(`"${value}" doesn't match any ${fieldLabel} option on ${scope}. Available: ${list}.`);
29
+ }
30
+ throw new Error(`"${value}" is ambiguous on ${scope} — matches: ${matches.map(o => `${o.value} (${o.id})`).join(', ')}. ` +
31
+ `Use the exact name or the numeric id.`);
32
+ }
@@ -291,6 +291,15 @@ async function handleExecuteJql(jiraClient, args) {
291
291
  // Set default pagination values
292
292
  const startAt = args.startAt !== undefined ? args.startAt : 0;
293
293
  const maxResults = args.maxResults !== undefined ? args.maxResults : 25;
294
+ // Validate field/function names and syntax before searching. The enhanced search endpoint
295
+ // silently returns zero results for an unknown field, so a typo'd field name would otherwise
296
+ // be indistinguishable from "that field is empty everywhere" (ADR-213 §A3, #44).
297
+ const jqlErrors = await jiraClient.parseJqlErrors(args.jql);
298
+ if (jqlErrors.length > 0) {
299
+ throw new McpError(ErrorCode.InvalidParams, `Invalid JQL — Jira rejected this query:\n${jqlErrors.map(e => ` - ${e}`).join('\n')}\n\n` +
300
+ `If you meant a custom field, check its exact name in the jira://custom-fields resource ` +
301
+ `(or jira://custom-fields/{projectKey}/{issueType} for fields on a specific issue type).`);
302
+ }
294
303
  try {
295
304
  console.error(`Executing JQL search with args:`, JSON.stringify(args, null, 2));
296
305
  // Parse search expansion options (not currently used but reserved for future)
@@ -1,6 +1,7 @@
1
1
  import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { fieldDiscovery } from '../client/field-discovery.js';
3
3
  import { categoryLabel } from '../client/field-type-map.js';
4
+ import { routeForField } from '../extensions/index.js';
4
5
  import { MarkdownRenderer } from '../mcp/markdown-renderer.js';
5
6
  import { bulkOperationGuard } from '../utils/bulk-operation-guard.js';
6
7
  import { issueNextSteps } from '../utils/next-steps.js';
@@ -120,7 +121,7 @@ function validateManageJiraIssueArgs(args) {
120
121
  if (!Array.isArray(normalizedArgs.expand)) {
121
122
  throw new McpError(ErrorCode.InvalidParams, 'Invalid expand parameter. Expected an array of strings.');
122
123
  }
123
- const validExpansions = ['comments', 'transitions', 'attachments', 'related_issues', 'history'];
124
+ const validExpansions = ['comments', 'transitions', 'attachments', 'related_issues', 'history', 'custom_fields'];
124
125
  for (const expansion of normalizedArgs.expand) {
125
126
  if (typeof expansion !== 'string' || !validExpansions.includes(expansion)) {
126
127
  throw new McpError(ErrorCode.InvalidParams, `Invalid expansion: ${expansion}. Valid expansions are: ${validExpansions.join(', ')}`);
@@ -143,6 +144,106 @@ function getCatalogFieldMeta() {
143
144
  description: f.description,
144
145
  }));
145
146
  }
147
+ /** Compact rendering of a field value for the "Applied" section. */
148
+ function formatAppliedValue(v) {
149
+ if (v === null || v === undefined)
150
+ return '(none)';
151
+ if (Array.isArray(v))
152
+ return v.map(formatAppliedValue).join(', ') || '(none)';
153
+ if (typeof v === 'object') {
154
+ const o = v;
155
+ if (typeof o.name === 'string')
156
+ return o.name;
157
+ if (typeof o.value === 'string')
158
+ return o.value;
159
+ if (typeof o.displayName === 'string')
160
+ return o.displayName;
161
+ const s = JSON.stringify(v);
162
+ return s.length > 80 ? s.slice(0, 77) + '…' : s;
163
+ }
164
+ const s = String(v);
165
+ return s.length > 80 ? s.slice(0, 77) + '…' : s;
166
+ }
167
+ /**
168
+ * Render an "Applied" section confirming what a create/update wrote (ADR-213 §A4, #48).
169
+ * For custom fields it cross-checks the re-fetched issue and flags silent drops — a write
170
+ * Jira accepts but discards (field off the Edit screen, hidden by a config, etc.).
171
+ */
172
+ function renderAppliedFields(args, issue) {
173
+ const lines = [];
174
+ if (args.summary !== undefined)
175
+ lines.push(`- **Summary**: ${formatAppliedValue(issue.summary ?? args.summary)}`);
176
+ if (args.description !== undefined)
177
+ lines.push(`- **Description**: ${issue.description ? `updated (${issue.description.length} chars)` : '(cleared)'}`);
178
+ if (args.priority !== undefined)
179
+ lines.push(`- **Priority**: ${formatAppliedValue(issue.priority)}`);
180
+ if (args.assignee !== undefined)
181
+ lines.push(`- **Assignee**: ${issue.assignee ?? '(unassigned)'}`);
182
+ if (args.labels !== undefined)
183
+ lines.push(`- **Labels**: ${(issue.labels && issue.labels.length) ? issue.labels.join(', ') : '(none)'}`);
184
+ if (args.dueDate !== undefined)
185
+ lines.push(`- **Due date**: ${issue.dueDate ?? '(cleared)'}`);
186
+ if (args.parent !== undefined)
187
+ lines.push(`- **Parent**: ${issue.parent ?? '(removed)'}`);
188
+ if (args.originalEstimate !== undefined)
189
+ lines.push(`- **Original estimate**: ${issue.originalEstimate ?? '(cleared)'}`);
190
+ if (args.remainingEstimate !== undefined)
191
+ lines.push(`- **Remaining estimate**: ${issue.timeEstimate ?? '(cleared)'}`);
192
+ if (args.customFields) {
193
+ const storedByName = new Map((issue.customFieldValues ?? []).map(v => [v.name, v.value]));
194
+ for (const [requestedName, requestedValue] of Object.entries(args.customFields)) {
195
+ const fieldId = requestedName.startsWith('customfield_') ? requestedName : fieldDiscovery.resolveNameToId(requestedName);
196
+ const catalogName = fieldId ? (fieldDiscovery.getFieldById(fieldId)?.name ?? requestedName) : requestedName;
197
+ const stored = storedByName.has(catalogName) ? storedByName.get(catalogName)
198
+ : storedByName.has(requestedName) ? storedByName.get(requestedName)
199
+ : undefined;
200
+ const requestedNonEmpty = requestedValue !== undefined && requestedValue !== null && requestedValue !== ''
201
+ && !(Array.isArray(requestedValue) && requestedValue.length === 0);
202
+ if (stored !== undefined) {
203
+ lines.push(`- **${requestedName}**: ${formatAppliedValue(stored)}`);
204
+ }
205
+ else if (requestedNonEmpty && fieldId && fieldDiscovery.getFieldById(fieldId)) {
206
+ lines.push(`- **${requestedName}**: ⚠️ requested \`${formatAppliedValue(requestedValue)}\` but Jira stored no value — the field may be off the Edit screen for this issue type, hidden by a field configuration, or require a dedicated API. It may still be editable inline in the Jira UI.`);
207
+ }
208
+ else {
209
+ lines.push(`- **${requestedName}**: ${formatAppliedValue(requestedValue)} (sent — could not verify)`);
210
+ }
211
+ }
212
+ }
213
+ return lines.length > 0 ? `\n\n**Applied:**\n${lines.join('\n')}` : '';
214
+ }
215
+ /** The extension route claiming a customFields payload key — checks the key directly (covers a raw
216
+ * field name or a Connect field key) and, for a `customfield_*` id, the resolved catalog name. */
217
+ function routeForPayloadKey(key) {
218
+ const direct = routeForField(key);
219
+ if (direct)
220
+ return direct;
221
+ if (key.startsWith('customfield_')) {
222
+ const name = fieldDiscovery.getFieldById(key)?.name;
223
+ if (name)
224
+ return routeForField(name);
225
+ }
226
+ return undefined;
227
+ }
228
+ /** True if any customFields key maps to an extension route with a value-resolving write handler. */
229
+ function customFieldsNeedRouting(customFields) {
230
+ return Object.keys(customFields).some(k => routeForPayloadKey(k)?.resolveWrite != null);
231
+ }
232
+ /**
233
+ * Apply extension routes' `resolveWrite` hooks to a resolved customFields map (ADR-213 §B) — e.g.
234
+ * turn a Tempo account name into the numeric id Jira expects. Throws if a value can't be resolved;
235
+ * callers re-throw as InvalidParams.
236
+ */
237
+ async function applyRouteResolutions(jiraClient, customFields, projectKey, issueTypeName) {
238
+ const out = {};
239
+ for (const [key, value] of Object.entries(customFields)) {
240
+ const route = routeForPayloadKey(key);
241
+ out[key] = route?.resolveWrite
242
+ ? await route.resolveWrite({ client: jiraClient.v3Client, projectKey, issueTypeName }, key, value)
243
+ : value;
244
+ }
245
+ return out;
246
+ }
146
247
  /** Resolve field names to IDs in customFields, returns resolved object */
147
248
  function resolveCustomFieldNames(customFields) {
148
249
  if (!fieldDiscovery.isReady())
@@ -191,6 +292,11 @@ function getUndescribedFieldNag() {
191
292
  function issueGuidance(operation, issueKey) {
192
293
  return issueNextSteps(operation, issueKey) + getUndescribedFieldNag();
193
294
  }
295
+ /** Extract the project key prefix from a Jira issue key (e.g. `PROJ-123` → `PROJ`).
296
+ * Uppercases the result so handlers don't drift from each other on casing. */
297
+ function projectKeyFromIssueKey(issueKey) {
298
+ return issueKey.split('-')[0].toUpperCase();
299
+ }
194
300
  // Handler functions for each operation
195
301
  async function handleGetIssue(jiraClient, args) {
196
302
  // Parse expansion options
@@ -204,14 +310,21 @@ async function handleGetIssue(jiraClient, args) {
204
310
  const includeComments = expansionOptions.comments || false;
205
311
  const includeAttachments = expansionOptions.attachments || false;
206
312
  const includeHistory = expansionOptions.history || false;
207
- const issue = await jiraClient.getIssue(args.issueKey, includeComments, includeAttachments, getCatalogFieldMeta(), includeHistory);
313
+ const includeAllCustomFields = expansionOptions.custom_fields || false;
314
+ const issue = await jiraClient.getIssue(args.issueKey, includeComments, includeAttachments, getCatalogFieldMeta(), includeHistory, includeAllCustomFields);
208
315
  // Get transitions if requested
209
316
  let transitions = undefined;
210
317
  if (expansionOptions.transitions) {
211
318
  transitions = await jiraClient.getTransitions(args.issueKey);
212
319
  }
213
- // Render to markdown
214
- const markdown = MarkdownRenderer.renderIssue(issue, transitions);
320
+ // Render to markdown — ADR-214: minimal-by-default custom-field reveal.
321
+ // `expand: ["custom_fields"]` opts into the full dump; otherwise emit a one-line breadcrumb
322
+ // pointing at the opt-in and the scoped `jira://custom-fields/{proj}/{type}` resource.
323
+ const markdown = MarkdownRenderer.renderIssue(issue, transitions, {
324
+ customFields: includeAllCustomFields ? 'dump' : 'breadcrumb',
325
+ projectKey: projectKeyFromIssueKey(args.issueKey),
326
+ issueTypeName: issue.issueType,
327
+ });
215
328
  return {
216
329
  content: [
217
330
  {
@@ -231,8 +344,9 @@ async function handleMoveIssue(jiraClient, args) {
231
344
  await jiraClient.moveIssue(issueKey, args.targetProjectKey, args.targetIssueType);
232
345
  bulkOperationGuard.record('move', issueKey);
233
346
  // Get the moved issue (it now has a new key in the target project)
234
- const movedIssue = await jiraClient.getIssue(issueKey, false, false);
235
- const markdown = MarkdownRenderer.renderIssue(movedIssue);
347
+ const movedIssue = await jiraClient.getIssue(issueKey, false, false, getCatalogFieldMeta());
348
+ // ADR-214: post-write — `Applied:` is the read-out; drop the custom-fields block entirely.
349
+ const markdown = MarkdownRenderer.renderIssue(movedIssue, undefined, { customFields: 'none' });
236
350
  return {
237
351
  content: [
238
352
  {
@@ -251,7 +365,9 @@ async function handleDeleteIssue(jiraClient, args) {
251
365
  }
252
366
  // Get the issue details before deleting for a final snapshot
253
367
  const issue = await jiraClient.getIssue(issueKey, false, false);
254
- const markdown = MarkdownRenderer.renderIssue(issue);
368
+ // ADR-214: post-write — the dump is noise on a soon-to-be-deleted issue, and the breadcrumb
369
+ // would point at expand/resource URIs that no longer apply once the delete lands.
370
+ const markdown = MarkdownRenderer.renderIssue(issue, undefined, { customFields: 'none' });
255
371
  await jiraClient.deleteIssue(issueKey);
256
372
  bulkOperationGuard.record('delete', issueKey);
257
373
  return {
@@ -264,7 +380,15 @@ async function handleDeleteIssue(jiraClient, args) {
264
380
  };
265
381
  }
266
382
  async function handleCreateIssue(jiraClient, args) {
267
- const customFields = args.customFields ? resolveCustomFieldNames(args.customFields) : undefined;
383
+ let customFields = args.customFields ? resolveCustomFieldNames(args.customFields) : undefined;
384
+ if (customFields && customFieldsNeedRouting(customFields)) {
385
+ try {
386
+ customFields = await applyRouteResolutions(jiraClient, customFields, args.projectKey, args.issueType);
387
+ }
388
+ catch (e) {
389
+ throw new McpError(ErrorCode.InvalidParams, e instanceof Error ? e.message : String(e));
390
+ }
391
+ }
268
392
  const result = await jiraClient.createIssue({
269
393
  projectKey: args.projectKey,
270
394
  summary: args.summary,
@@ -278,19 +402,32 @@ async function handleCreateIssue(jiraClient, args) {
278
402
  customFields,
279
403
  });
280
404
  // Get the created issue and render to markdown
281
- const createdIssue = await jiraClient.getIssue(result.key, false, false);
282
- const markdown = MarkdownRenderer.renderIssue(createdIssue);
405
+ const createdIssue = await jiraClient.getIssue(result.key, false, false, getCatalogFieldMeta(), false, !!args.customFields);
406
+ // ADR-214: post-write — `Applied:` is the read-out; drop the custom-fields block entirely.
407
+ const markdown = MarkdownRenderer.renderIssue(createdIssue, undefined, { customFields: 'none' });
408
+ const applied = renderAppliedFields(args, createdIssue);
283
409
  return {
284
410
  content: [
285
411
  {
286
412
  type: 'text',
287
- text: `# Issue Created\n\n${markdown}${issueGuidance('create', result.key)}`,
413
+ text: `# Issue Created\n\n${markdown}${applied}${issueGuidance('create', result.key)}`,
288
414
  },
289
415
  ],
290
416
  };
291
417
  }
292
418
  async function handleUpdateIssue(jiraClient, args) {
293
- const customFields = args.customFields ? resolveCustomFieldNames(args.customFields) : undefined;
419
+ let customFields = args.customFields ? resolveCustomFieldNames(args.customFields) : undefined;
420
+ if (customFields && customFieldsNeedRouting(customFields)) {
421
+ // createmeta-based resolution needs the issue's type; the project key is the key prefix.
422
+ const probe = await jiraClient.getIssue(args.issueKey, false, false);
423
+ const projectKey = projectKeyFromIssueKey(args.issueKey);
424
+ try {
425
+ customFields = await applyRouteResolutions(jiraClient, customFields, projectKey, probe.issueType);
426
+ }
427
+ catch (e) {
428
+ throw new McpError(ErrorCode.InvalidParams, e instanceof Error ? e.message : String(e));
429
+ }
430
+ }
294
431
  await jiraClient.updateIssue({
295
432
  issueKey: args.issueKey,
296
433
  summary: args.summary,
@@ -305,13 +442,15 @@ async function handleUpdateIssue(jiraClient, args) {
305
442
  customFields,
306
443
  });
307
444
  // Get the updated issue and render to markdown
308
- const updatedIssue = await jiraClient.getIssue(args.issueKey, false, false);
309
- const markdown = MarkdownRenderer.renderIssue(updatedIssue);
445
+ const updatedIssue = await jiraClient.getIssue(args.issueKey, false, false, getCatalogFieldMeta(), false, !!args.customFields);
446
+ // ADR-214: post-write — `Applied:` is the read-out; drop the custom-fields block entirely.
447
+ const markdown = MarkdownRenderer.renderIssue(updatedIssue, undefined, { customFields: 'none' });
448
+ const applied = renderAppliedFields(args, updatedIssue);
310
449
  return {
311
450
  content: [
312
451
  {
313
452
  type: 'text',
314
- text: `# Issue Updated\n\n${markdown}${issueGuidance('update', args.issueKey)}`,
453
+ text: `# Issue Updated\n\n${markdown}${applied}${issueGuidance('update', args.issueKey)}`,
315
454
  },
316
455
  ],
317
456
  };
@@ -319,8 +458,10 @@ async function handleUpdateIssue(jiraClient, args) {
319
458
  async function handleTransitionIssue(jiraClient, args) {
320
459
  await jiraClient.transitionIssue(args.issueKey, args.transitionId, args.comment);
321
460
  // Get the updated issue and render to markdown
322
- const updatedIssue = await jiraClient.getIssue(args.issueKey, false, false);
323
- const markdown = MarkdownRenderer.renderIssue(updatedIssue);
461
+ const updatedIssue = await jiraClient.getIssue(args.issueKey, false, false, getCatalogFieldMeta());
462
+ // ADR-214: post-write — transition has no `Applied:` section, but the custom-fields dump still
463
+ // adds ~600 tokens of noise unrelated to the state change. Drop it.
464
+ const markdown = MarkdownRenderer.renderIssue(updatedIssue, undefined, { customFields: 'none' });
324
465
  return {
325
466
  content: [
326
467
  {
@@ -334,7 +475,8 @@ async function handleCommentIssue(jiraClient, args) {
334
475
  await jiraClient.addComment(args.issueKey, args.comment);
335
476
  // Get the updated issue with comments and render to markdown
336
477
  const updatedIssue = await jiraClient.getIssue(args.issueKey, true, false);
337
- const markdown = MarkdownRenderer.renderIssue(updatedIssue);
478
+ // ADR-214: post-write — the new comment is the read-out; custom-field block is noise.
479
+ const markdown = MarkdownRenderer.renderIssue(updatedIssue, undefined, { customFields: 'none' });
338
480
  return {
339
481
  content: [
340
482
  {
@@ -350,7 +492,8 @@ async function handleLinkIssue(jiraClient, args) {
350
492
  await jiraClient.linkIssues(args.issueKey, args.linkedIssueKey, args.linkType, args.comment);
351
493
  // Get the updated issue and render to markdown
352
494
  const updatedIssue = await jiraClient.getIssue(args.issueKey, false, false);
353
- const markdown = MarkdownRenderer.renderIssue(updatedIssue);
495
+ // ADR-214: post-write — the new link is the read-out; custom-field block is noise.
496
+ const markdown = MarkdownRenderer.renderIssue(updatedIssue, undefined, { customFields: 'none' });
354
497
  return {
355
498
  content: [
356
499
  {
@@ -372,7 +515,8 @@ async function handleWorklogIssue(jiraClient, args) {
372
515
  });
373
516
  // Get the updated issue and render to markdown
374
517
  const updatedIssue = await jiraClient.getIssue(args.issueKey, false, false);
375
- const markdown = MarkdownRenderer.renderIssue(updatedIssue);
518
+ // ADR-214: post-write — the logged worklog is the read-out; custom-field block is noise.
519
+ const markdown = MarkdownRenderer.renderIssue(updatedIssue, undefined, { customFields: 'none' });
376
520
  return {
377
521
  content: [
378
522
  {
@@ -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
+ }
@@ -3,6 +3,7 @@ import { setupToolResourceHandlers } from './tool-resource-handlers.js';
3
3
  import { fieldDiscovery } from '../client/field-discovery.js';
4
4
  import { categoryLabel } from '../client/field-type-map.js';
5
5
  import { searchGoals } from '../client/graphql-goals.js';
6
+ import { allFieldRoutes, moduleStatuses } from '../extensions/index.js';
6
7
  /**
7
8
  * Sets up resource handlers for the Jira MCP server
8
9
  * @param jiraClient The Jira client instance
@@ -49,6 +50,12 @@ export function setupResourceHandlers(jiraClient, graphqlClient) {
49
50
  mimeType: 'text/markdown',
50
51
  description: 'Composition patterns for analyze_jira_issues — how to combine summary counts, groupBy, and JQL for PM dashboards'
51
52
  },
53
+ {
54
+ uri: 'jira://capabilities',
55
+ name: 'Connection Capabilities',
56
+ mimeType: 'application/json',
57
+ description: 'What this connection can do — custom-field catalog mode, Plans availability, and fields that need special handling (Sprint, Epic Link, Tempo Account, …)'
58
+ },
52
59
  // Add tool resources
53
60
  ...toolResources.resources
54
61
  ]
@@ -103,6 +110,9 @@ export function setupResourceHandlers(jiraClient, graphqlClient) {
103
110
  if (uri === 'jira://analysis/recipes') {
104
111
  return getAnalysisRecipes();
105
112
  }
113
+ if (uri === 'jira://capabilities') {
114
+ return await getCapabilities(graphqlClient);
115
+ }
106
116
  // Handle resource templates
107
117
  const projectMatch = uri.match(/^jira:\/\/projects\/([^/]+)\/overview$/);
108
118
  if (projectMatch) {
@@ -321,12 +331,56 @@ async function getBoardOverview(jiraClient, boardId) {
321
331
  throw error;
322
332
  }
323
333
  }
334
+ /** In unscored mode the catalog can be large; the resource truncates past this many fields. */
335
+ const MAX_UNSCORED_RESOURCE_FIELDS = 200;
324
336
  /**
325
- * Gets the master custom fields catalog
337
+ * Gets the master custom fields catalog (ADR-201, ADR-213 §A1).
338
+ *
339
+ * Three states (see {@link CatalogMode}):
340
+ * - `scored` — curated, ranked catalog with descriptions, screen counts, and recency
341
+ * - `unscored` — admin field API returned 403; flat id/name/type list of every custom field
342
+ * (no descriptions — the basic field API doesn't carry them), truncated for size with a
343
+ * pointer to the project-scoped resource for richer per-field detail
344
+ * - `unavailable` / `loading` — no catalog yet
326
345
  */
327
346
  function getCustomFieldsCatalog() {
347
+ const state = fieldDiscovery.getState();
328
348
  const catalog = fieldDiscovery.getCatalog();
329
349
  const stats = fieldDiscovery.getStats();
350
+ const error = fieldDiscovery.getError();
351
+ if (state === 'loading') {
352
+ return jsonResource('jira://custom-fields', { status: 'loading', fields: [], count: 0 });
353
+ }
354
+ if (state === 'unavailable') {
355
+ return jsonResource('jira://custom-fields', {
356
+ status: 'unavailable',
357
+ fields: [],
358
+ count: 0,
359
+ error: error ?? 'Custom field discovery failed.',
360
+ });
361
+ }
362
+ if (state === 'unscored') {
363
+ const total = catalog.length;
364
+ const truncated = total > MAX_UNSCORED_RESOURCE_FIELDS;
365
+ const fields = (truncated ? catalog.slice(0, MAX_UNSCORED_RESOURCE_FIELDS) : catalog).map(f => ({
366
+ id: f.id,
367
+ name: f.name,
368
+ type: categoryLabel(f.category),
369
+ writable: f.writable,
370
+ }));
371
+ return jsonResource('jira://custom-fields', {
372
+ status: 'ready',
373
+ mode: 'unscored',
374
+ fields,
375
+ count: total,
376
+ ...(truncated ? { truncated: true, shown: fields.length } : {}),
377
+ note: (error ? error + ' ' : '') +
378
+ 'Fields are not ranked by screen usage or recency, and descriptions are not available in this mode. ' +
379
+ 'For descriptions and allowed values on the fields relevant to a specific project and issue type, read ' +
380
+ 'jira://custom-fields/{projectKey}/{issueType}.',
381
+ });
382
+ }
383
+ // scored
330
384
  const fields = catalog.map(f => ({
331
385
  id: f.id,
332
386
  name: f.name,
@@ -338,7 +392,8 @@ function getCustomFieldsCatalog() {
338
392
  score: f.score,
339
393
  }));
340
394
  const response = {
341
- status: fieldDiscovery.isReady() ? 'ready' : 'loading',
395
+ status: 'ready',
396
+ mode: 'scored',
342
397
  fields,
343
398
  count: fields.length,
344
399
  };
@@ -353,17 +408,52 @@ function getCustomFieldsCatalog() {
353
408
  undescribedRatio: Math.round(stats.undescribedRatio * 100),
354
409
  };
355
410
  }
356
- if (fieldDiscovery.getError()) {
357
- response.error = fieldDiscovery.getError();
358
- }
411
+ return jsonResource('jira://custom-fields', response);
412
+ }
413
+ /** Wrap an object as a single JSON resource content entry. */
414
+ function jsonResource(uri, body) {
359
415
  return {
360
416
  contents: [{
361
- uri: 'jira://custom-fields',
417
+ uri,
362
418
  mimeType: 'application/json',
363
- text: JSON.stringify(response, null, 2),
419
+ text: JSON.stringify(body, null, 2),
364
420
  }],
365
421
  };
366
422
  }
423
+ /**
424
+ * What this connection can actually do (ADR-213 §B): the custom-field catalog mode, whether the
425
+ * Plans/GraphQL surface is reachable, the loaded extension modules and their detection state, and
426
+ * the field routes those modules contribute (fields that need handling beyond a plain customFields
427
+ * write — Sprint, Epic Link, Tempo Account, …). `resolves: true` on a route means this server can
428
+ * transform the value for you (e.g. an account name → id); otherwise the route's guidance names the
429
+ * path to use.
430
+ */
431
+ async function getCapabilities(graphqlClient) {
432
+ const catalogState = fieldDiscovery.getState();
433
+ const catalog = fieldDiscovery.getCatalog();
434
+ const catalogError = fieldDiscovery.getError();
435
+ const fieldRouting = allFieldRoutes().map(r => ({
436
+ names: r.names,
437
+ resolves: !!r.resolveWrite,
438
+ reason: r.unhandled.reason,
439
+ guidance: r.unhandled.message,
440
+ ...(r.unhandled.suggestedTool ? { suggestedTool: r.unhandled.suggestedTool } : {}),
441
+ }));
442
+ return jsonResource('jira://capabilities', {
443
+ customFieldCatalog: {
444
+ mode: catalogState,
445
+ count: catalog.length,
446
+ ...(catalogState !== 'scored' && catalogError ? { note: catalogError } : {}),
447
+ },
448
+ plans: { available: graphqlClient != null },
449
+ extensions: await moduleStatuses(),
450
+ fieldRouting,
451
+ note: 'Extension modules + capability-aware field routing (ADR-213 §B). Each fieldRouting entry ' +
452
+ 'describes a field that needs handling beyond a plain customFields write; `resolves: true` ' +
453
+ 'means this server transforms the value for you (pass a name or an id), otherwise `guidance` ' +
454
+ 'names the right path. Read jira://custom-fields for the field catalog.',
455
+ });
456
+ }
367
457
  /**
368
458
  * Gets custom fields available for a specific project + issue type (context intersection)
369
459
  */
@@ -385,7 +475,7 @@ async function getContextCustomFields(jiraClient, projectKey, issueType) {
385
475
  issueType,
386
476
  fields: result,
387
477
  count: result.length,
388
- catalogReady: fieldDiscovery.isReady(),
478
+ catalogMode: fieldDiscovery.getState(),
389
479
  }, null, 2),
390
480
  }],
391
481
  };