@aaronsb/jira-cloud-mcp 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -1131,6 +1186,47 @@ export class JiraClient {
1131
1186
  jql: result.jql || '',
1132
1187
  };
1133
1188
  }
1189
+ // ── Attachment Operations ────────────────────────────────
1190
+ async getAttachmentInfo(attachmentId) {
1191
+ const meta = await this.client.issueAttachments.getAttachment(attachmentId);
1192
+ return {
1193
+ id: meta.id?.toString() ?? attachmentId,
1194
+ filename: meta.filename ?? 'unnamed',
1195
+ mimeType: meta.mimeType ?? 'application/octet-stream',
1196
+ size: meta.size ?? 0,
1197
+ created: meta.created ?? '',
1198
+ author: meta.author?.displayName ?? 'Unknown',
1199
+ url: meta.content ?? '',
1200
+ };
1201
+ }
1202
+ async downloadAttachment(attachmentId) {
1203
+ const content = await this.client.issueAttachments.getAttachmentContent(attachmentId);
1204
+ // jira.js generic defaults to Buffer; ensure we return Buffer even if runtime type differs
1205
+ return Buffer.isBuffer(content) ? content : Buffer.from(content);
1206
+ }
1207
+ async uploadAttachment(issueKey, filename, content, mimeType) {
1208
+ const result = await this.client.issueAttachments.addAttachment({
1209
+ issueIdOrKey: issueKey,
1210
+ attachment: {
1211
+ filename,
1212
+ file: content,
1213
+ mimeType,
1214
+ },
1215
+ });
1216
+ const att = Array.isArray(result) ? result[0] : result;
1217
+ return {
1218
+ id: att.id?.toString() ?? '',
1219
+ filename: att.filename ?? filename,
1220
+ mimeType: att.mimeType ?? mimeType,
1221
+ size: att.size ?? content.length,
1222
+ created: att.created ?? new Date().toISOString(),
1223
+ author: att.author?.displayName ?? 'Unknown',
1224
+ url: att.content ?? '',
1225
+ };
1226
+ }
1227
+ async deleteAttachment(attachmentId) {
1228
+ await this.client.issueAttachments.removeAttachment(attachmentId);
1229
+ }
1134
1230
  async deleteFilter(filterId) {
1135
1231
  await this.client.filters.deleteFilter(filterId);
1136
1232
  }
@@ -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
+ };