@aaronsb/jira-cloud-mcp 0.10.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.
@@ -27,13 +27,17 @@ export class FieldDiscovery {
27
27
  idToField = new Map();
28
28
  wellKnown = new Map(); // logical name → field ID
29
29
  stats = null;
30
- ready = false;
30
+ mode = 'loading';
31
31
  error = null;
32
- /** Whether the catalog has been built */
32
+ /** Whether a usable catalog exists (scored or unscored). */
33
33
  isReady() {
34
- return this.ready;
34
+ return this.mode === 'scored' || this.mode === 'unscored';
35
35
  }
36
- /** Error message if startup discovery failed */
36
+ /** Granular catalog state see {@link CatalogMode}. */
37
+ getState() {
38
+ return this.mode;
39
+ }
40
+ /** Error message if discovery failed (or degraded). */
37
41
  getError() {
38
42
  return this.error;
39
43
  }
@@ -67,7 +71,7 @@ export class FieldDiscovery {
67
71
  * Falls back to the full writable catalog if the createmeta call fails.
68
72
  */
69
73
  async getContextFields(client, projectKey, issueTypeName) {
70
- if (!this.ready || this.catalog.length === 0) {
74
+ if (!this.isReady() || this.catalog.length === 0) {
71
75
  return [];
72
76
  }
73
77
  try {
@@ -190,11 +194,70 @@ export class FieldDiscovery {
190
194
  }
191
195
  /** Clear required fields cache for a project (on 400 errors, cache may be stale). */
192
196
  invalidateRequiredFields(projectKey) {
197
+ const prefix = projectKey.toLowerCase() + ':';
193
198
  for (const key of this.requiredFieldsCache.keys()) {
194
- if (key.startsWith(projectKey.toLowerCase() + ':')) {
199
+ if (key.startsWith(prefix))
195
200
  this.requiredFieldsCache.delete(key);
201
+ }
202
+ for (const key of this.fieldOptionsCache.keys()) {
203
+ if (key.startsWith(prefix))
204
+ this.fieldOptionsCache.delete(key);
205
+ }
206
+ }
207
+ // ── Field Options (createmeta allowedValues) ─────────────────────────
208
+ fieldOptionsCache = new Map();
209
+ /**
210
+ * Enumerable allowed values (`{id, value}`) for a field on a project + issue type, read from
211
+ * createmeta. Returns `[]` if the field isn't on that issue type's create screen or has no
212
+ * enumerable options. Cached per project/issue-type — one createmeta walk covers every field.
213
+ * Used to resolve a human-friendly option name to the id the issue-edit endpoint expects
214
+ * (e.g. the Tempo Account field — see ADR-213 §B / field-routing.ts).
215
+ */
216
+ async getFieldAllowedValues(client, projectKey, issueTypeName, fieldId) {
217
+ const cacheKey = `${projectKey}:${issueTypeName}`.toLowerCase();
218
+ let perField = this.fieldOptionsCache.get(cacheKey);
219
+ if (!perField) {
220
+ perField = new Map();
221
+ try {
222
+ const issueTypes = await this.getIssueTypes(client, projectKey);
223
+ const matchingType = issueTypes.find(t => t.name.toLowerCase() === issueTypeName.toLowerCase());
224
+ if (matchingType) {
225
+ let startAt = 0;
226
+ const maxResults = 50;
227
+ let hasMore = true;
228
+ while (hasMore) {
229
+ const fieldMeta = await client.issues.getCreateIssueMetaIssueTypeId({
230
+ projectIdOrKey: projectKey,
231
+ issueTypeId: matchingType.id,
232
+ startAt,
233
+ maxResults,
234
+ });
235
+ const fields = (fieldMeta.fields || fieldMeta.results || []);
236
+ for (const f of fields) {
237
+ if (!f.fieldId || !Array.isArray(f.allowedValues) || f.allowedValues.length === 0)
238
+ continue;
239
+ const opts = [];
240
+ for (const v of f.allowedValues) {
241
+ const id = v?.id ?? v?.value;
242
+ const value = v?.value ?? v?.name ?? (typeof v === 'string' ? v : undefined);
243
+ if (id !== undefined && value !== undefined)
244
+ opts.push({ id, value: String(value) });
245
+ }
246
+ if (opts.length > 0)
247
+ perField.set(f.fieldId, opts);
248
+ }
249
+ if (fields.length < maxResults)
250
+ hasMore = false;
251
+ startAt += fields.length;
252
+ }
253
+ }
254
+ }
255
+ catch (err) {
256
+ console.error(`[field-discovery] Field options fetch failed for ${projectKey}/${issueTypeName}: ${err instanceof Error ? err.message : err}`);
196
257
  }
258
+ this.fieldOptionsCache.set(cacheKey, perField);
197
259
  }
260
+ return perField.get(fieldId) ?? [];
198
261
  }
199
262
  /**
200
263
  * Start discovery in the background. Does not block.
@@ -208,13 +271,41 @@ export class FieldDiscovery {
208
271
  }
209
272
  /**
210
273
  * Build the master catalog from Jira's field metadata.
274
+ *
275
+ * Tries the admin-only paginated field-search API first (full metadata → `scored` mode).
276
+ * On any failure (typically a 403 for non-admin users — see issue #43) falls back to the
277
+ * basic field list, which any authenticated user can read, and builds an `unscored` catalog.
278
+ * If even that fails, the catalog stays empty (`unavailable`) and custom fields pass through
279
+ * unfiltered, as before.
211
280
  */
212
281
  async discover(client) {
282
+ console.error('[field-discovery] Starting custom field discovery...');
283
+ let rawFields;
284
+ let degraded = false;
285
+ try {
286
+ rawFields = await this.fetchAllCustomFields(client);
287
+ console.error(`[field-discovery] Fetched ${rawFields.length} custom fields (full metadata)`);
288
+ }
289
+ catch (err) {
290
+ const msg = err instanceof Error ? err.message : String(err);
291
+ // Keep the underlying cause around — it explains the degraded mode in the resource.
292
+ this.error = `The admin field-search API is unavailable (${msg}) — likely because this user lacks the Administer Jira global permission. Running custom-field discovery in unscored mode.`;
293
+ console.error(`[field-discovery] Admin field-search API unavailable (${msg}); falling back to the basic field list`);
294
+ try {
295
+ rawFields = await this.fetchCustomFieldsBasic(client);
296
+ degraded = true;
297
+ console.error(`[field-discovery] Fetched ${rawFields.length} custom fields (basic list — no screen/recency metadata)`);
298
+ }
299
+ catch (err2) {
300
+ const msg2 = err2 instanceof Error ? err2.message : String(err2);
301
+ this.error = `Custom field discovery failed. Admin field-search API: ${msg}. Basic field list: ${msg2}.`;
302
+ this.mode = 'unavailable';
303
+ console.error(`[field-discovery] ${this.error}`);
304
+ return;
305
+ }
306
+ }
213
307
  try {
214
- console.error('[field-discovery] Starting custom field discovery...');
215
- const rawFields = await this.fetchAllCustomFields(client);
216
- console.error(`[field-discovery] Fetched ${rawFields.length} custom fields`);
217
- // Detect well-known locked fields by schema type (before filtering)
308
+ // Detect well-known locked fields by schema custom type (works in both modes)
218
309
  for (const field of rawFields) {
219
310
  const logicalName = WELL_KNOWN_FIELDS[field.schemaCustom];
220
311
  if (logicalName) {
@@ -222,31 +313,48 @@ export class FieldDiscovery {
222
313
  console.error(`[field-discovery] Well-known: ${logicalName} → ${field.id} (${field.name})`);
223
314
  }
224
315
  }
225
- const { qualified, stats } = this.filterAndClassify(rawFields);
226
- console.error(`[field-discovery] ${qualified.length} fields passed filters`);
227
- const scored = this.scoreFields(qualified);
228
- const finalCatalog = this.applyCutoff(scored);
229
- this.catalog = finalCatalog;
230
- this.stats = { ...stats, catalogSize: finalCatalog.length };
231
- this.buildIndexes();
232
- this.ready = true;
233
- console.error(`[field-discovery] Catalog ready: ${finalCatalog.length} fields`);
234
- if (stats.undescribedRatio > 0.5) {
235
- console.error(`[field-discovery] WARNING: ${Math.round(stats.undescribedRatio * 100)}% of on-screen custom fields lack descriptions. ` +
236
- `Encourage your Jira admin to add descriptions for better AI support.`);
316
+ if (degraded) {
317
+ this.catalog = this.buildUnscoredCatalog(rawFields);
318
+ this.stats = {
319
+ totalCustomFields: rawFields.length,
320
+ excludedNoDescription: 0,
321
+ excludedNoScreens: 0,
322
+ excludedUnsupportedType: 0,
323
+ excludedLocked: 0,
324
+ catalogSize: this.catalog.length,
325
+ undescribedRatio: 0,
326
+ };
327
+ this.buildIndexes();
328
+ this.mode = 'unscored';
329
+ console.error(`[field-discovery] Catalog ready (unscored): ${this.catalog.length} fields`);
330
+ }
331
+ else {
332
+ const { qualified, stats } = this.filterAndClassify(rawFields);
333
+ console.error(`[field-discovery] ${qualified.length} fields passed filters`);
334
+ const scored = this.scoreFields(qualified);
335
+ const finalCatalog = this.applyCutoff(scored);
336
+ this.catalog = finalCatalog;
337
+ this.stats = { ...stats, catalogSize: finalCatalog.length };
338
+ this.buildIndexes();
339
+ this.mode = 'scored';
340
+ console.error(`[field-discovery] Catalog ready (scored): ${finalCatalog.length} fields`);
341
+ if (stats.undescribedRatio > 0.5) {
342
+ console.error(`[field-discovery] WARNING: ${Math.round(stats.undescribedRatio * 100)}% of on-screen custom fields lack descriptions. ` +
343
+ `Encourage your Jira admin to add descriptions for better AI support.`);
344
+ }
345
+ this.logExclusions(rawFields, finalCatalog);
237
346
  }
238
- // Log excluded fields at debug level
239
- this.logExclusions(rawFields, finalCatalog);
240
347
  }
241
348
  catch (err) {
242
349
  const msg = err instanceof Error ? err.message : String(err);
243
350
  this.error = msg;
244
- console.error(`[field-discovery] Discovery failed: ${msg}`);
245
- // Don't re-throw catalog stays empty, fields pass through unfiltered
351
+ this.mode = 'unavailable';
352
+ console.error(`[field-discovery] Catalog build failed: ${msg}`);
246
353
  }
247
354
  }
248
355
  /**
249
- * Fetch all custom fields with metadata via paginated API.
356
+ * Fetch all custom fields with full metadata via the admin paginated field-search API.
357
+ * Requires the Administer Jira global permission — throws (typically 403) otherwise.
250
358
  */
251
359
  async fetchAllCustomFields(client) {
252
360
  const allFields = [];
@@ -282,6 +390,53 @@ export class FieldDiscovery {
282
390
  }
283
391
  return allFields;
284
392
  }
393
+ /**
394
+ * Fetch custom fields via the basic field list (`GET /rest/api/3/field`), which any
395
+ * authenticated user can read. Carries id / name / schema / `custom` flag but **not**
396
+ * `description`, `screensCount`, `lastUsed`, or `isLocked` — so the resulting catalog is
397
+ * `unscored` (no ranking, no cutoff). Used as the non-admin fallback for issue #43.
398
+ */
399
+ async fetchCustomFieldsBasic(client) {
400
+ const all = await client.issueFields.getFields();
401
+ return (all || [])
402
+ .filter(f => f?.id && (f.custom === true || f.id.startsWith('customfield_')))
403
+ .map(f => ({
404
+ id: f.id,
405
+ name: f.name || f.id,
406
+ description: '',
407
+ isLocked: false,
408
+ screensCount: 0,
409
+ lastUsed: null,
410
+ lastUsedType: 'NO_INFORMATION',
411
+ schemaType: f.schema?.type || '',
412
+ schemaCustom: f.schema?.custom || '',
413
+ schemaItems: f.schema?.items || '',
414
+ }));
415
+ }
416
+ /**
417
+ * Build an unscored catalog: every custom field, no exclusions, no ranking, sorted by name.
418
+ * Type classification still runs (drives the `writable` flag and `category`), but unsupported
419
+ * types are kept rather than dropped — name→ID resolution should cover every field.
420
+ */
421
+ buildUnscoredCatalog(rawFields) {
422
+ return rawFields
423
+ .map(f => {
424
+ const typeInfo = classifyFieldType(f.schemaType, f.schemaCustom || undefined, f.schemaItems || undefined);
425
+ return {
426
+ id: f.id,
427
+ name: f.name,
428
+ description: f.description,
429
+ category: typeInfo.category,
430
+ writable: typeInfo.writable,
431
+ jsonSchema: typeInfo.jsonSchema,
432
+ schemaCustom: f.schemaCustom,
433
+ screensCount: 0,
434
+ lastUsed: null,
435
+ score: 0,
436
+ };
437
+ })
438
+ .sort((a, b) => a.name.localeCompare(b.name));
439
+ }
285
440
  /**
286
441
  * Apply qualification filters (ADR-201 §Qualification Criteria).
287
442
  */
@@ -387,6 +542,7 @@ export class FieldDiscovery {
387
542
  category: f.typeInfo.category,
388
543
  writable: f.typeInfo.writable,
389
544
  jsonSchema: f.typeInfo.jsonSchema,
545
+ schemaCustom: f.schemaCustom,
390
546
  screensCount: f.screensCount,
391
547
  lastUsed: f.lastUsed,
392
548
  score: Math.round(f.score * 100) / 100,
@@ -1,5 +1,10 @@
1
1
  import { Version3Client, AgileClient } from 'jira.js';
2
2
  import { TextProcessor } from '../utils/text-processing.js';
3
+ /**
4
+ * Above this many catalog custom fields, `getIssue` requests `*navigable` instead of
5
+ * enumerating every field ID — keeps the request small in unscored-catalog mode (ADR-213 §A2).
6
+ */
7
+ const CUSTOM_FIELD_ID_REQUEST_LIMIT = 50;
3
8
  export class JiraClient {
4
9
  client;
5
10
  agileClient;
@@ -160,16 +165,25 @@ export class JiraClient {
160
165
  people: people.length > 0 ? people : undefined,
161
166
  };
162
167
  }
163
- async getIssue(issueKey, includeComments = false, includeAttachments = false, customFieldMeta, includeHistory = false) {
168
+ async getIssue(issueKey, includeComments = false, includeAttachments = false, customFieldMeta, includeHistory = false, includeAllCustomFields = false) {
164
169
  const fields = [...this.issueFields];
165
170
  if (includeAttachments) {
166
171
  fields.push('attachment');
167
172
  }
168
- // Include discovered custom field IDs in the fetch
173
+ // Decide how to fetch custom fields:
174
+ // - small catalog (scored mode, ≤ ~30 fields) → request the catalog's IDs explicitly
175
+ // - large catalog (unscored mode) or explicit `custom_fields` expand → request all navigable
176
+ // fields once (`*navigable`) and label whatever populated custom fields come back
177
+ const useNavigableWildcard = !!customFieldMeta && (includeAllCustomFields || customFieldMeta.length > CUSTOM_FIELD_ID_REQUEST_LIMIT);
169
178
  if (customFieldMeta) {
170
- for (const cf of customFieldMeta) {
171
- if (!fields.includes(cf.id)) {
172
- fields.push(cf.id);
179
+ if (useNavigableWildcard) {
180
+ fields.push('*navigable');
181
+ }
182
+ else {
183
+ for (const cf of customFieldMeta) {
184
+ if (!fields.includes(cf.id)) {
185
+ fields.push(cf.id);
186
+ }
173
187
  }
174
188
  }
175
189
  }
@@ -199,21 +213,41 @@ export class JiraClient {
199
213
  })))
200
214
  .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
201
215
  }
202
- // Extract custom field values using catalog metadata
216
+ // Extract custom field values, labeled via the catalog metadata.
203
217
  if (customFieldMeta) {
204
218
  const rawFields = issue.fields;
219
+ const metaById = new Map(customFieldMeta.map(m => [m.id, m]));
205
220
  const customValues = [];
206
- for (const cf of customFieldMeta) {
207
- const value = rawFields[cf.id];
208
- if (value !== undefined && value !== null) {
221
+ const isPopulated = (v) => v !== undefined && v !== null
222
+ && !(Array.isArray(v) && v.length === 0)
223
+ && !(typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0);
224
+ if (useNavigableWildcard) {
225
+ // The response carries every navigable custom field; surface the populated ones.
226
+ for (const [key, value] of Object.entries(rawFields)) {
227
+ if (!/^customfield_\d+$/.test(key) || !isPopulated(value))
228
+ continue;
229
+ const meta = metaById.get(key);
209
230
  customValues.push({
210
- name: cf.name,
231
+ name: meta?.name ?? key,
211
232
  value: this.formatCustomFieldValue(value),
212
- type: cf.type,
213
- description: cf.description,
233
+ type: meta?.type ?? 'unknown',
234
+ description: meta?.description ?? '',
214
235
  });
215
236
  }
216
237
  }
238
+ else {
239
+ for (const cf of customFieldMeta) {
240
+ const value = rawFields[cf.id];
241
+ if (isPopulated(value)) {
242
+ customValues.push({
243
+ name: cf.name,
244
+ value: this.formatCustomFieldValue(value),
245
+ type: cf.type,
246
+ description: cf.description,
247
+ });
248
+ }
249
+ }
250
+ }
217
251
  if (customValues.length > 0) {
218
252
  issueDetails.customFieldValues = customValues;
219
253
  }
@@ -593,6 +627,27 @@ export class JiraClient {
593
627
  throw error;
594
628
  }
595
629
  }
630
+ /**
631
+ * Validate a JQL query's structure and field references via POST /rest/api/3/jql/parse
632
+ * (ADR-213 §A3, #44). The enhanced search endpoint silently returns zero results for an
633
+ * unknown field, so this is the only way to distinguish "field doesn't exist" from "field
634
+ * is empty across all issues". Returns the list of syntax/validation errors — empty if valid.
635
+ * If the parse endpoint itself fails, returns [] (don't block the search on a flaky validator).
636
+ */
637
+ async parseJqlErrors(jql) {
638
+ try {
639
+ const cleanJql = jql.replace(/\\"/g, '"');
640
+ const result = await this.client.jql.parseJqlQueries({
641
+ queries: [cleanJql],
642
+ validation: 'warn',
643
+ });
644
+ return result?.queries?.[0]?.errors ?? [];
645
+ }
646
+ catch (err) {
647
+ console.error(`[jql-parse] validation skipped: ${err instanceof Error ? err.message : String(err)}`);
648
+ return [];
649
+ }
650
+ }
596
651
  async searchIssues(jql, startAt = 0, maxResults = 25) {
597
652
  try {
598
653
  // Remove escaped quotes from JQL
@@ -33,7 +33,7 @@ function generateIssueToolDocumentation(schema) {
33
33
  description: "Retrieves issue details",
34
34
  required_parameters: ["issueKey"],
35
35
  optional_parameters: ["expand"],
36
- expand_options: ["comments", "transitions", "attachments", "related_issues", "history"],
36
+ expand_options: ["comments", "transitions", "attachments", "related_issues", "history", "custom_fields"],
37
37
  examples: [
38
38
  {
39
39
  description: "Get basic issue details",
@@ -162,11 +162,14 @@ function generateIssueToolDocumentation(schema) {
162
162
  }
163
163
  ],
164
164
  custom_fields: {
165
- description: "Custom fields are discovered automatically at startup. Use field names (not IDs) in the customFields parameter.",
166
- discovery: "Read jira://custom-fields to see the master catalog of discovered fields with types and descriptions.",
167
- context: "Read jira://custom-fields/{projectKey}/{issueType} to see fields valid for a specific project and issue type.",
168
- name_resolution: "Field names are resolved to IDs automatically use human-readable names like 'Story Points', not 'customfield_10035'.",
169
- requirement: "Only fields with descriptions in Jira are discoverable. Fields without descriptions must use raw field IDs.",
165
+ description: "Custom fields are discovered automatically. Pass values in customFields as { name | customfield_NNNNN : value } — names are preferred.",
166
+ discovery: "Read jira://custom-fields for the master catalog of discovered fields with types and descriptions.",
167
+ context: "Read jira://custom-fields/{projectKey}/{issueType} for fields settable on a specific screen, with jsonSchema hints.",
168
+ capabilities: "Read jira://capabilities for the fieldRouting tableflags fields that need a dedicated tool or special handling. Examples: Sprint goes through manage_jira_sprint (operation: manage_issues); Epic Link / Parent Link / Parent go through the `parent` parameter on update, not customFields; Rank (backlog order) is not settable through this server; Tempo Account auto-resolves a name or numeric id via the project's account options.",
169
+ name_resolution: "Field names resolve to ids automatically prefer 'Story Points' over 'customfield_10035'. Catalog lookup is case-insensitive.",
170
+ clear_semantics: "Pass null to clear a field. Empty string ('') may collide with name-resolution on extension-routed fields (e.g., Tempo Account: '' matches all account names ambiguously) — null is the safe path.",
171
+ view_after_write: "Populated custom fields are NOT rendered on `get` by default — a one-line breadcrumb names the count and the opt-in. Pass `expand: [\"custom_fields\"]` to render the full block. Post-write renders drop the block entirely; the `Applied:` section is the read-out.",
172
+ requirement: "Only fields with descriptions in Jira are discoverable. Fields without descriptions must be referenced by raw customfield_NNNNN id.",
170
173
  },
171
174
  related_resources: [
172
175
  { name: "Project Overview", uri: "jira://projects/{projectKey}/overview", description: "Get detailed information about a project" },
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Atlassian special-field handling (ADR-213 §B).
3
+ *
4
+ * Sprint, Epic Link / Parent Link / Parent, and Rank are Jira-native fields that can't be set
5
+ * through a plain `customFields` write — each has a dedicated path. This module owns the routes that
6
+ * say so; `classifyFieldErrors` surfaces the guidance when Jira rejects such a write. There's no
7
+ * `detect()` — these fields are intrinsic to Jira, always "present".
8
+ */
9
+ export const atlassianSpecialFields = {
10
+ id: 'atlassian-special-fields',
11
+ displayName: 'Atlassian special fields (Sprint, Epic Link, Rank)',
12
+ routes: [
13
+ {
14
+ names: ['sprint'],
15
+ unhandled: {
16
+ reason: 'sprint_requires_agile_api',
17
+ message: 'The Sprint field is managed through the Agile API, not customFields. Use ' +
18
+ 'manage_jira_sprint with operation "manage_issues" to add the issue to a sprint (find the ' +
19
+ 'sprint id via manage_jira_sprint list / list_boards).',
20
+ suggestedTool: 'manage_jira_sprint',
21
+ },
22
+ },
23
+ {
24
+ names: ['epic link', 'parent link', 'parent'],
25
+ unhandled: {
26
+ reason: 'parent_via_parent_param',
27
+ message: 'Epic Link / Parent Link / Parent are set via the "parent" parameter on ' +
28
+ 'manage_jira_issue update (pass the parent issue key) — not customFields. Use ' +
29
+ 'manage_jira_issue hierarchy to see the current parent/child structure.',
30
+ suggestedTool: 'manage_jira_issue',
31
+ },
32
+ },
33
+ {
34
+ names: ['rank'],
35
+ unhandled: {
36
+ reason: 'rank_not_exposed',
37
+ message: 'Issue Rank (backlog ordering) is not settable through this server — reorder issues on a ' +
38
+ 'board in the Jira UI.',
39
+ },
40
+ },
41
+ ],
42
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Extension-module registry (ADR-213 §B).
3
+ *
4
+ * `MODULES` is the *explicit, hand-curated* list of extension modules — there is no auto-discovery
5
+ * or glob. Adding a module = importing it here and adding it to the list = a reviewed edit, justified
6
+ * by a common user flow. This is the composition point: the rest of the server asks here for the
7
+ * field routes (issue create/update + error classification) and for the per-module status
8
+ * (jira://capabilities).
9
+ */
10
+ import { atlassianSpecialFields } from './atlassian-special-fields.js';
11
+ import { tempo } from './tempo.js';
12
+ const MODULES = [atlassianSpecialFields, tempo];
13
+ export function allModules() {
14
+ return MODULES;
15
+ }
16
+ /** Every field route contributed by every module. */
17
+ export function allFieldRoutes() {
18
+ return MODULES.flatMap(m => m.routes);
19
+ }
20
+ /** Find the route claiming a given field name, catalog name, or Connect field key, if any. */
21
+ export function routeForField(nameOrKey) {
22
+ const lower = nameOrKey.toLowerCase();
23
+ for (const m of MODULES) {
24
+ const r = m.routes.find(rt => rt.names.some(n => n.toLowerCase() === lower));
25
+ if (r)
26
+ return r;
27
+ }
28
+ return undefined;
29
+ }
30
+ /**
31
+ * Per-module status for jira://capabilities. Runs each module's `detect()` (cheap in-memory checks
32
+ * against the field catalog) fresh each call — fail-open. Not cached: detection only reads state
33
+ * that may still be warming up at startup, and re-running it is microseconds.
34
+ */
35
+ export async function moduleStatuses() {
36
+ const out = [];
37
+ for (const m of MODULES) {
38
+ const fields = m.routes.flatMap(r => r.names);
39
+ if (!m.detect) {
40
+ out.push({ id: m.id, displayName: m.displayName, fields });
41
+ continue;
42
+ }
43
+ try {
44
+ const d = await m.detect();
45
+ out.push({ id: m.id, displayName: m.displayName, present: d.present, notes: d.notes, fields });
46
+ }
47
+ catch {
48
+ out.push({ id: m.id, displayName: m.displayName, notes: 'detection skipped (error)', fields });
49
+ }
50
+ }
51
+ return out;
52
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Tempo extension module (ADR-213 §B).
3
+ *
4
+ * Bounds: the Tempo **Account** field. The standard Jira issue-edit endpoint accepts it, but
5
+ * expects the numeric Tempo account id — not the account name. `resolveWrite` turns a name string
6
+ * into that id using the field's allowed values from createmeta (project-scoped) — so no Tempo API
7
+ * token is needed. `detect()` reports whether a Tempo-managed field is present in the field catalog.
8
+ *
9
+ * (Tempo Team and worklog attributes are deliberately out of scope for now — see the ADR. The
10
+ * modern Tempo Cloud API at api.tempo.io has its own token; if a future handler needs it, that's
11
+ * when a `TEMPO_API_TOKEN` mechanism gets added — the `requires` hook on a route is for that case.)
12
+ */
13
+ import { matchAllowedValue } from './types.js';
14
+ import { fieldDiscovery } from '../client/field-discovery.js';
15
+ /** Tempo's Connect/Forge field-key marker — appears in schema `custom` types and in Jira's
16
+ * field-error keys for Tempo-managed fields (e.g. `io.tempo.jira__account`). */
17
+ const TEMPO_FIELD_MARKER = /io\.tempo\./i;
18
+ /** Resolve the Account field's `customFields` value to the numeric id Jira expects. */
19
+ async function resolveTempoAccountWrite(ctx, fieldId, value) {
20
+ if (typeof value === 'number')
21
+ return value;
22
+ if (value !== null && typeof value === 'object')
23
+ return value; // already `{id: ...}` or similar
24
+ if (typeof value !== 'string')
25
+ return value; // unknown shape — let Jira reject it
26
+ const trimmed = value.trim();
27
+ if (/^\d+$/.test(trimmed))
28
+ return Number(trimmed); // bare numeric string → the id
29
+ const options = await fieldDiscovery.getFieldAllowedValues(ctx.client, ctx.projectKey, ctx.issueTypeName, fieldId);
30
+ if (options.length === 0) {
31
+ throw new Error(`Couldn't resolve the Account value "${value}" — the Account field exposes no enumerable ` +
32
+ `options on ${ctx.projectKey}/${ctx.issueTypeName} (it may not be on that issue type's screen, ` +
33
+ `or no Tempo accounts are linked to this project). Pass the numeric Tempo account id directly ` +
34
+ `(customFields: {"Account": <id>}), or set it in the Jira UI.`);
35
+ }
36
+ return matchAllowedValue(value, options, 'Account', ctx.projectKey);
37
+ }
38
+ const accountRoute = {
39
+ names: ['account', 'tempo account', 'io.tempo.jira__account'],
40
+ resolveWrite: resolveTempoAccountWrite,
41
+ unhandled: {
42
+ reason: 'tempo_account_value_format',
43
+ message: 'The Account field is provided by the Tempo app. Pass the account *name* (e.g. ' +
44
+ '"OpEx - Praecipio AI Dev") or its numeric id — this server resolves a name to the id via the ' +
45
+ 'project\'s field options. If it still fails, the account may not be linked to this project, ' +
46
+ 'or the field may not be on this issue type\'s screen; setting it inline in the Jira UI works.',
47
+ },
48
+ };
49
+ export const tempo = {
50
+ id: 'tempo',
51
+ displayName: 'Tempo (Account field)',
52
+ routes: [accountRoute],
53
+ async detect() {
54
+ try {
55
+ const hits = fieldDiscovery.getCatalog().filter(f => TEMPO_FIELD_MARKER.test(f.schemaCustom));
56
+ if (hits.length === 0)
57
+ return { present: false };
58
+ return { present: true, notes: `Tempo-managed field(s): ${hits.map(f => `${f.name} (${f.id})`).join(', ')}` };
59
+ }
60
+ catch {
61
+ return { present: false, notes: 'detection skipped (field catalog unavailable)' };
62
+ }
63
+ },
64
+ };
@@ -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
  {
@@ -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.0",
4
4
  "mcpName": "io.github.aaronsb/jira-cloud",
5
5
  "description": "Model Context Protocol (MCP) server for Jira Cloud - enables AI assistants to interact with Jira",
6
6
  "type": "module",