@aaronsb/jira-cloud-mcp 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@
6
6
  * pass through unfiltered (no regression from pre-discovery behavior).
7
7
  */
8
8
  import { classifyFieldType } from './field-type-map.js';
9
+ import { routeForField } from '../extensions/index.js';
9
10
  // ── Constants ──────────────────────────────────────────────────────────
10
11
  const HARD_CAP = 30;
11
12
  const SPREAD_RATIO_THRESHOLD = 10;
@@ -14,6 +15,18 @@ const SCREEN_WEIGHT = 10;
14
15
  const RECENCY_WEIGHT = 5;
15
16
  const RECENCY_HALF_LIFE_DAYS = 30;
16
17
  // ── Field Discovery ────────────────────────────────────────────────────
18
+ /**
19
+ * True if an extension route owns a value-resolving write handler for this field (ADR-213 §B).
20
+ * Used to mark `writable: true` on a field whose Jira type our classifier can't map — e.g. Tempo
21
+ * Account reports an opaque schema (→ `unsupported` category) but `resolveTempoAccountWrite` turns
22
+ * a name into the numeric id the standard issue-edit endpoint accepts. Matches the route by field
23
+ * name first, then by schema-custom key. (#45 follow-up: keeps the catalog `writable` flag in
24
+ * step with what the write path actually does.)
25
+ */
26
+ function extensionCanWrite(fieldName, schemaCustom) {
27
+ const route = routeForField(fieldName) ?? (schemaCustom ? routeForField(schemaCustom) : undefined);
28
+ return route?.resolveWrite != null;
29
+ }
17
30
  /** Well-known locked fields identified by schema custom type */
18
31
  const WELL_KNOWN_FIELDS = {
19
32
  'com.pyxis.greenhopper.jira:gh-sprint': 'sprint',
@@ -27,13 +40,17 @@ export class FieldDiscovery {
27
40
  idToField = new Map();
28
41
  wellKnown = new Map(); // logical name → field ID
29
42
  stats = null;
30
- ready = false;
43
+ mode = 'loading';
31
44
  error = null;
32
- /** Whether the catalog has been built */
45
+ /** Whether a usable catalog exists (scored or unscored). */
33
46
  isReady() {
34
- return this.ready;
47
+ return this.mode === 'scored' || this.mode === 'unscored';
48
+ }
49
+ /** Granular catalog state — see {@link CatalogMode}. */
50
+ getState() {
51
+ return this.mode;
35
52
  }
36
- /** Error message if startup discovery failed */
53
+ /** Error message if discovery failed (or degraded). */
37
54
  getError() {
38
55
  return this.error;
39
56
  }
@@ -67,7 +84,7 @@ export class FieldDiscovery {
67
84
  * Falls back to the full writable catalog if the createmeta call fails.
68
85
  */
69
86
  async getContextFields(client, projectKey, issueTypeName) {
70
- if (!this.ready || this.catalog.length === 0) {
87
+ if (!this.isReady() || this.catalog.length === 0) {
71
88
  return [];
72
89
  }
73
90
  try {
@@ -190,11 +207,70 @@ export class FieldDiscovery {
190
207
  }
191
208
  /** Clear required fields cache for a project (on 400 errors, cache may be stale). */
192
209
  invalidateRequiredFields(projectKey) {
210
+ const prefix = projectKey.toLowerCase() + ':';
193
211
  for (const key of this.requiredFieldsCache.keys()) {
194
- if (key.startsWith(projectKey.toLowerCase() + ':')) {
212
+ if (key.startsWith(prefix))
195
213
  this.requiredFieldsCache.delete(key);
214
+ }
215
+ for (const key of this.fieldOptionsCache.keys()) {
216
+ if (key.startsWith(prefix))
217
+ this.fieldOptionsCache.delete(key);
218
+ }
219
+ }
220
+ // ── Field Options (createmeta allowedValues) ─────────────────────────
221
+ fieldOptionsCache = new Map();
222
+ /**
223
+ * Enumerable allowed values (`{id, value}`) for a field on a project + issue type, read from
224
+ * createmeta. Returns `[]` if the field isn't on that issue type's create screen or has no
225
+ * enumerable options. Cached per project/issue-type — one createmeta walk covers every field.
226
+ * Used to resolve a human-friendly option name to the id the issue-edit endpoint expects
227
+ * (e.g. the Tempo Account field — see ADR-213 §B / field-routing.ts).
228
+ */
229
+ async getFieldAllowedValues(client, projectKey, issueTypeName, fieldId) {
230
+ const cacheKey = `${projectKey}:${issueTypeName}`.toLowerCase();
231
+ let perField = this.fieldOptionsCache.get(cacheKey);
232
+ if (!perField) {
233
+ perField = new Map();
234
+ try {
235
+ const issueTypes = await this.getIssueTypes(client, projectKey);
236
+ const matchingType = issueTypes.find(t => t.name.toLowerCase() === issueTypeName.toLowerCase());
237
+ if (matchingType) {
238
+ let startAt = 0;
239
+ const maxResults = 50;
240
+ let hasMore = true;
241
+ while (hasMore) {
242
+ const fieldMeta = await client.issues.getCreateIssueMetaIssueTypeId({
243
+ projectIdOrKey: projectKey,
244
+ issueTypeId: matchingType.id,
245
+ startAt,
246
+ maxResults,
247
+ });
248
+ const fields = (fieldMeta.fields || fieldMeta.results || []);
249
+ for (const f of fields) {
250
+ if (!f.fieldId || !Array.isArray(f.allowedValues) || f.allowedValues.length === 0)
251
+ continue;
252
+ const opts = [];
253
+ for (const v of f.allowedValues) {
254
+ const id = v?.id ?? v?.value;
255
+ const value = v?.value ?? v?.name ?? (typeof v === 'string' ? v : undefined);
256
+ if (id !== undefined && value !== undefined)
257
+ opts.push({ id, value: String(value) });
258
+ }
259
+ if (opts.length > 0)
260
+ perField.set(f.fieldId, opts);
261
+ }
262
+ if (fields.length < maxResults)
263
+ hasMore = false;
264
+ startAt += fields.length;
265
+ }
266
+ }
267
+ }
268
+ catch (err) {
269
+ console.error(`[field-discovery] Field options fetch failed for ${projectKey}/${issueTypeName}: ${err instanceof Error ? err.message : err}`);
196
270
  }
271
+ this.fieldOptionsCache.set(cacheKey, perField);
197
272
  }
273
+ return perField.get(fieldId) ?? [];
198
274
  }
199
275
  /**
200
276
  * Start discovery in the background. Does not block.
@@ -208,13 +284,41 @@ export class FieldDiscovery {
208
284
  }
209
285
  /**
210
286
  * Build the master catalog from Jira's field metadata.
287
+ *
288
+ * Tries the admin-only paginated field-search API first (full metadata → `scored` mode).
289
+ * On any failure (typically a 403 for non-admin users — see issue #43) falls back to the
290
+ * basic field list, which any authenticated user can read, and builds an `unscored` catalog.
291
+ * If even that fails, the catalog stays empty (`unavailable`) and custom fields pass through
292
+ * unfiltered, as before.
211
293
  */
212
294
  async discover(client) {
295
+ console.error('[field-discovery] Starting custom field discovery...');
296
+ let rawFields;
297
+ let degraded = false;
213
298
  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)
299
+ rawFields = await this.fetchAllCustomFields(client);
300
+ console.error(`[field-discovery] Fetched ${rawFields.length} custom fields (full metadata)`);
301
+ }
302
+ catch (err) {
303
+ const msg = err instanceof Error ? err.message : String(err);
304
+ // Keep the underlying cause around — it explains the degraded mode in the resource.
305
+ 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.`;
306
+ console.error(`[field-discovery] Admin field-search API unavailable (${msg}); falling back to the basic field list`);
307
+ try {
308
+ rawFields = await this.fetchCustomFieldsBasic(client);
309
+ degraded = true;
310
+ console.error(`[field-discovery] Fetched ${rawFields.length} custom fields (basic list — no screen/recency metadata)`);
311
+ }
312
+ catch (err2) {
313
+ const msg2 = err2 instanceof Error ? err2.message : String(err2);
314
+ this.error = `Custom field discovery failed. Admin field-search API: ${msg}. Basic field list: ${msg2}.`;
315
+ this.mode = 'unavailable';
316
+ console.error(`[field-discovery] ${this.error}`);
317
+ return;
318
+ }
319
+ }
320
+ try {
321
+ // Detect well-known locked fields by schema custom type (works in both modes)
218
322
  for (const field of rawFields) {
219
323
  const logicalName = WELL_KNOWN_FIELDS[field.schemaCustom];
220
324
  if (logicalName) {
@@ -222,31 +326,48 @@ export class FieldDiscovery {
222
326
  console.error(`[field-discovery] Well-known: ${logicalName} → ${field.id} (${field.name})`);
223
327
  }
224
328
  }
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.`);
329
+ if (degraded) {
330
+ this.catalog = this.buildUnscoredCatalog(rawFields);
331
+ this.stats = {
332
+ totalCustomFields: rawFields.length,
333
+ excludedNoDescription: 0,
334
+ excludedNoScreens: 0,
335
+ excludedUnsupportedType: 0,
336
+ excludedLocked: 0,
337
+ catalogSize: this.catalog.length,
338
+ undescribedRatio: 0,
339
+ };
340
+ this.buildIndexes();
341
+ this.mode = 'unscored';
342
+ console.error(`[field-discovery] Catalog ready (unscored): ${this.catalog.length} fields`);
343
+ }
344
+ else {
345
+ const { qualified, stats } = this.filterAndClassify(rawFields);
346
+ console.error(`[field-discovery] ${qualified.length} fields passed filters`);
347
+ const scored = this.scoreFields(qualified);
348
+ const finalCatalog = this.applyCutoff(scored);
349
+ this.catalog = finalCatalog;
350
+ this.stats = { ...stats, catalogSize: finalCatalog.length };
351
+ this.buildIndexes();
352
+ this.mode = 'scored';
353
+ console.error(`[field-discovery] Catalog ready (scored): ${finalCatalog.length} fields`);
354
+ if (stats.undescribedRatio > 0.5) {
355
+ console.error(`[field-discovery] WARNING: ${Math.round(stats.undescribedRatio * 100)}% of on-screen custom fields lack descriptions. ` +
356
+ `Encourage your Jira admin to add descriptions for better AI support.`);
357
+ }
358
+ this.logExclusions(rawFields, finalCatalog);
237
359
  }
238
- // Log excluded fields at debug level
239
- this.logExclusions(rawFields, finalCatalog);
240
360
  }
241
361
  catch (err) {
242
362
  const msg = err instanceof Error ? err.message : String(err);
243
363
  this.error = msg;
244
- console.error(`[field-discovery] Discovery failed: ${msg}`);
245
- // Don't re-throw catalog stays empty, fields pass through unfiltered
364
+ this.mode = 'unavailable';
365
+ console.error(`[field-discovery] Catalog build failed: ${msg}`);
246
366
  }
247
367
  }
248
368
  /**
249
- * Fetch all custom fields with metadata via paginated API.
369
+ * Fetch all custom fields with full metadata via the admin paginated field-search API.
370
+ * Requires the Administer Jira global permission — throws (typically 403) otherwise.
250
371
  */
251
372
  async fetchAllCustomFields(client) {
252
373
  const allFields = [];
@@ -282,6 +403,53 @@ export class FieldDiscovery {
282
403
  }
283
404
  return allFields;
284
405
  }
406
+ /**
407
+ * Fetch custom fields via the basic field list (`GET /rest/api/3/field`), which any
408
+ * authenticated user can read. Carries id / name / schema / `custom` flag but **not**
409
+ * `description`, `screensCount`, `lastUsed`, or `isLocked` — so the resulting catalog is
410
+ * `unscored` (no ranking, no cutoff). Used as the non-admin fallback for issue #43.
411
+ */
412
+ async fetchCustomFieldsBasic(client) {
413
+ const all = await client.issueFields.getFields();
414
+ return (all || [])
415
+ .filter(f => f?.id && (f.custom === true || f.id.startsWith('customfield_')))
416
+ .map(f => ({
417
+ id: f.id,
418
+ name: f.name || f.id,
419
+ description: '',
420
+ isLocked: false,
421
+ screensCount: 0,
422
+ lastUsed: null,
423
+ lastUsedType: 'NO_INFORMATION',
424
+ schemaType: f.schema?.type || '',
425
+ schemaCustom: f.schema?.custom || '',
426
+ schemaItems: f.schema?.items || '',
427
+ }));
428
+ }
429
+ /**
430
+ * Build an unscored catalog: every custom field, no exclusions, no ranking, sorted by name.
431
+ * Type classification still runs (drives the `writable` flag and `category`), but unsupported
432
+ * types are kept rather than dropped — name→ID resolution should cover every field.
433
+ */
434
+ buildUnscoredCatalog(rawFields) {
435
+ return rawFields
436
+ .map(f => {
437
+ const typeInfo = classifyFieldType(f.schemaType, f.schemaCustom || undefined, f.schemaItems || undefined);
438
+ return {
439
+ id: f.id,
440
+ name: f.name,
441
+ description: f.description,
442
+ category: typeInfo.category,
443
+ writable: typeInfo.writable || extensionCanWrite(f.name, f.schemaCustom),
444
+ jsonSchema: typeInfo.jsonSchema,
445
+ schemaCustom: f.schemaCustom,
446
+ screensCount: 0,
447
+ lastUsed: null,
448
+ score: 0,
449
+ };
450
+ })
451
+ .sort((a, b) => a.name.localeCompare(b.name));
452
+ }
285
453
  /**
286
454
  * Apply qualification filters (ADR-201 §Qualification Criteria).
287
455
  */
@@ -318,6 +486,10 @@ export class FieldDiscovery {
318
486
  continue;
319
487
  }
320
488
  // Filter: supported type
489
+ // NOTE: this also drops extension-handled fields whose Jira type our classifier can't map
490
+ // (e.g. Tempo Account on an admin tenant) — the unscored fallback keeps them and flags them
491
+ // writable via `extensionCanWrite`, but the scored path doesn't. Retaining them here is a
492
+ // larger change (it shifts what's in the curated catalog, not just a flag). See #45 / #57.
321
493
  const typeInfo = classifyFieldType(field.schemaType, field.schemaCustom || undefined, field.schemaItems || undefined);
322
494
  if (typeInfo.category === 'unsupported') {
323
495
  excludedUnsupportedType++;
@@ -387,6 +559,7 @@ export class FieldDiscovery {
387
559
  category: f.typeInfo.category,
388
560
  writable: f.typeInfo.writable,
389
561
  jsonSchema: f.typeInfo.jsonSchema,
562
+ schemaCustom: f.schemaCustom,
390
563
  screensCount: f.screensCount,
391
564
  lastUsed: f.lastUsed,
392
565
  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
+ }