@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.
- package/build/client/field-discovery.js +183 -27
- package/build/client/jira-client.js +67 -12
- package/build/docs/tool-documentation.js +9 -6
- package/build/extensions/atlassian-special-fields.js +42 -0
- package/build/extensions/index.js +52 -0
- package/build/extensions/tempo.js +64 -0
- package/build/extensions/types.js +32 -0
- package/build/handlers/filter-handlers.js +9 -0
- package/build/handlers/issue-handlers.js +164 -20
- package/build/handlers/resource-handlers.js +98 -8
- package/build/index.js +6 -0
- package/build/mcp/markdown-renderer.js +20 -7
- package/build/schemas/tool-schemas.js +4 -4
- package/build/utils/field-error-classification.js +65 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
30
|
+
mode = 'loading';
|
|
31
31
|
error = null;
|
|
32
|
-
/** Whether
|
|
32
|
+
/** Whether a usable catalog exists (scored or unscored). */
|
|
33
33
|
isReady() {
|
|
34
|
-
return this.
|
|
34
|
+
return this.mode === 'scored' || this.mode === 'unscored';
|
|
35
35
|
}
|
|
36
|
-
/**
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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:
|
|
231
|
+
name: meta?.name ?? key,
|
|
211
232
|
value: this.formatCustomFieldValue(value),
|
|
212
|
-
type:
|
|
213
|
-
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
|
|
166
|
-
discovery: "Read jira://custom-fields
|
|
167
|
-
context: "Read jira://custom-fields/{projectKey}/{issueType}
|
|
168
|
-
|
|
169
|
-
|
|
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 table — flags 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
357
|
-
|
|
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
|
|
417
|
+
uri,
|
|
362
418
|
mimeType: 'application/json',
|
|
363
|
-
text: JSON.stringify(
|
|
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
|
-
|
|
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 (
|
|
174
|
-
|
|
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
|
|
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
|
|
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