@aaronsb/jira-cloud-mcp 0.10.0 → 0.11.1

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.
@@ -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
  {
@@ -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
  };
package/build/index.js CHANGED
@@ -21,6 +21,7 @@ import { handleWorkspaceRequest } from './handlers/workspace-handler.js';
21
21
  import { promptDefinitions } from './prompts/prompt-definitions.js';
22
22
  import { getPrompt } from './prompts/prompt-messages.js';
23
23
  import { toolSchemas } from './schemas/tool-schemas.js';
24
+ import { classifyFieldErrors } from './utils/field-error-classification.js';
24
25
  import { normalizeArgs } from './utils/normalize-args.js';
25
26
  // Jira credentials from environment variables
26
27
  const JIRA_EMAIL = process.env.JIRA_EMAIL;
@@ -248,6 +249,11 @@ class JiraServer {
248
249
  for (const [field, msg] of Object.entries(fieldErrors)) {
249
250
  lines.push(`- \`${field}\`: ${msg}`);
250
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
+ }
251
257
  }
252
258
  // On create failures, invalidate cache and append required fields guidance
253
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'],
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
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",