@aaronsb/jira-cloud-mcp 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/client/field-discovery.js +183 -27
- package/build/client/jira-client.js +108 -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/media-handler.js +130 -0
- package/build/handlers/resource-handlers.js +98 -8
- package/build/handlers/workspace-handler.js +214 -0
- package/build/index.js +11 -0
- package/build/mcp/markdown-renderer.js +20 -7
- package/build/schemas/tool-schemas.js +71 -5
- package/build/utils/field-error-classification.js +65 -0
- package/build/utils/next-steps.js +16 -1
- package/build/workspace/index.js +1 -0
- package/build/workspace/workspace.js +187 -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
|
|
@@ -1131,6 +1186,47 @@ export class JiraClient {
|
|
|
1131
1186
|
jql: result.jql || '',
|
|
1132
1187
|
};
|
|
1133
1188
|
}
|
|
1189
|
+
// ── Attachment Operations ────────────────────────────────
|
|
1190
|
+
async getAttachmentInfo(attachmentId) {
|
|
1191
|
+
const meta = await this.client.issueAttachments.getAttachment(attachmentId);
|
|
1192
|
+
return {
|
|
1193
|
+
id: meta.id?.toString() ?? attachmentId,
|
|
1194
|
+
filename: meta.filename ?? 'unnamed',
|
|
1195
|
+
mimeType: meta.mimeType ?? 'application/octet-stream',
|
|
1196
|
+
size: meta.size ?? 0,
|
|
1197
|
+
created: meta.created ?? '',
|
|
1198
|
+
author: meta.author?.displayName ?? 'Unknown',
|
|
1199
|
+
url: meta.content ?? '',
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
async downloadAttachment(attachmentId) {
|
|
1203
|
+
const content = await this.client.issueAttachments.getAttachmentContent(attachmentId);
|
|
1204
|
+
// jira.js generic defaults to Buffer; ensure we return Buffer even if runtime type differs
|
|
1205
|
+
return Buffer.isBuffer(content) ? content : Buffer.from(content);
|
|
1206
|
+
}
|
|
1207
|
+
async uploadAttachment(issueKey, filename, content, mimeType) {
|
|
1208
|
+
const result = await this.client.issueAttachments.addAttachment({
|
|
1209
|
+
issueIdOrKey: issueKey,
|
|
1210
|
+
attachment: {
|
|
1211
|
+
filename,
|
|
1212
|
+
file: content,
|
|
1213
|
+
mimeType,
|
|
1214
|
+
},
|
|
1215
|
+
});
|
|
1216
|
+
const att = Array.isArray(result) ? result[0] : result;
|
|
1217
|
+
return {
|
|
1218
|
+
id: att.id?.toString() ?? '',
|
|
1219
|
+
filename: att.filename ?? filename,
|
|
1220
|
+
mimeType: att.mimeType ?? mimeType,
|
|
1221
|
+
size: att.size ?? content.length,
|
|
1222
|
+
created: att.created ?? new Date().toISOString(),
|
|
1223
|
+
author: att.author?.displayName ?? 'Unknown',
|
|
1224
|
+
url: att.content ?? '',
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
async deleteAttachment(attachmentId) {
|
|
1228
|
+
await this.client.issueAttachments.removeAttachment(attachmentId);
|
|
1229
|
+
}
|
|
1134
1230
|
async deleteFilter(filterId) {
|
|
1135
1231
|
await this.client.filters.deleteFilter(filterId);
|
|
1136
1232
|
}
|
|
@@ -33,7 +33,7 @@ function generateIssueToolDocumentation(schema) {
|
|
|
33
33
|
description: "Retrieves issue details",
|
|
34
34
|
required_parameters: ["issueKey"],
|
|
35
35
|
optional_parameters: ["expand"],
|
|
36
|
-
expand_options: ["comments", "transitions", "attachments", "related_issues", "history"],
|
|
36
|
+
expand_options: ["comments", "transitions", "attachments", "related_issues", "history", "custom_fields"],
|
|
37
37
|
examples: [
|
|
38
38
|
{
|
|
39
39
|
description: "Get basic issue details",
|
|
@@ -162,11 +162,14 @@ function generateIssueToolDocumentation(schema) {
|
|
|
162
162
|
}
|
|
163
163
|
],
|
|
164
164
|
custom_fields: {
|
|
165
|
-
description: "Custom fields are discovered automatically
|
|
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
|
+
};
|