@aaronsb/jira-cloud-mcp 0.2.7 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,8 +4,6 @@ A Model Context Protocol server for interacting with Jira Cloud instances.
4
4
 
5
5
  ## Quick Start
6
6
 
7
- ### Installation
8
-
9
7
  Add to your MCP settings:
10
8
 
11
9
  ```json
@@ -30,40 +28,34 @@ Or install globally:
30
28
  npm install -g @aaronsb/jira-cloud-mcp
31
29
  ```
32
30
 
33
- For detailed installation instructions, see the [Getting Started Guide](./docs/getting-started.md).
34
-
35
- ## Key Features
36
-
37
- - **Issue Management**: Create, retrieve, update, and comment on Jira issues
38
- - **Search Capabilities**: JQL search with filtering
39
- - **Project & Board Management**: List and manage projects, boards, and sprints
40
- - **MCP Resources**: Access Jira data as context through MCP resources
41
- - **Tool Documentation**: Comprehensive documentation for each tool available as MCP resources
42
- - **Customization**: Support for custom fields and workflows
31
+ ### Credentials
43
32
 
44
- ## Architecture
33
+ Generate an API token at [Atlassian Account Settings](https://id.atlassian.com/manage/api-tokens).
45
34
 
46
- The server is built with a modular architecture:
35
+ ## Tools
47
36
 
48
- ```
49
- src/
50
- ├── client/ # Core Jira API client
51
- ├── handlers/ # MCP tool handlers
52
- ├── schemas/ # JSON schemas for tools
53
- ├── types/ # TypeScript definitions
54
- ├── utils/ # Utility functions
55
- └── index.ts # Server entry point
56
- ```
37
+ | Tool | Description |
38
+ |------|-------------|
39
+ | `manage_jira_issue` | Get, create, update, delete, move, transition, comment on, or link Jira issues |
40
+ | `manage_jira_filter` | Search for issues using JQL queries, or manage saved filters |
41
+ | `manage_jira_project` | List projects or get project details including status counts |
42
+ | `manage_jira_board` | List boards or get board details and configuration |
43
+ | `manage_jira_sprint` | Manage sprints: create, start, close, and assign issues to sprints |
44
+ | `queue_jira_operations` | Execute multiple operations in one call with result references and error strategies |
57
45
 
58
- ## Documentation
46
+ Each tool accepts an `operation` parameter (except `queue_jira_operations` which takes an `operations` array). Detailed documentation is available as MCP resources at `jira://tools/{tool_name}/documentation`.
59
47
 
60
- Essential documentation is available in the `docs/` directory:
48
+ ## MCP Resources
61
49
 
62
- - [Getting Started](./docs/getting-started.md) - Setup and installation
63
- - [API Reference](./docs/api-reference.md) - Available tools and usage
64
- - [Resources](./docs/resources.md) - Available MCP resources
65
- - [Troubleshooting](./docs/troubleshooting.md) - Common issues and solutions
66
- - [Development](./docs/development.md) - Development guide
50
+ | Resource | Description |
51
+ |----------|-------------|
52
+ | `jira://instance/summary` | Instance-level statistics |
53
+ | `jira://projects/{key}/overview` | Project overview with status counts |
54
+ | `jira://boards/{id}/overview` | Board overview with sprint info |
55
+ | `jira://issue-link-types` | Available issue link types |
56
+ | `jira://custom-fields` | Custom field catalog (auto-discovered at startup) |
57
+ | `jira://custom-fields/{project}/{issueType}` | Context-specific custom fields |
58
+ | `jira://tools/{name}/documentation` | Tool documentation |
67
59
 
68
60
  ## License
69
61
 
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Dynamic custom field discovery (ADR-201).
3
+ *
4
+ * Builds a master catalog of interesting custom fields at startup.
5
+ * Runs asynchronously — before the catalog is ready, custom fields
6
+ * pass through unfiltered (no regression from pre-discovery behavior).
7
+ */
8
+ import { classifyFieldType } from './field-type-map.js';
9
+ // ── Constants ──────────────────────────────────────────────────────────
10
+ const HARD_CAP = 30;
11
+ const SPREAD_RATIO_THRESHOLD = 10;
12
+ // Scoring weights
13
+ const SCREEN_WEIGHT = 10;
14
+ const RECENCY_WEIGHT = 5;
15
+ const RECENCY_HALF_LIFE_DAYS = 30;
16
+ // ── Field Discovery ────────────────────────────────────────────────────
17
+ export class FieldDiscovery {
18
+ catalog = [];
19
+ nameToId = new Map();
20
+ idToField = new Map();
21
+ stats = null;
22
+ ready = false;
23
+ error = null;
24
+ /** Whether the catalog has been built */
25
+ isReady() {
26
+ return this.ready;
27
+ }
28
+ /** Error message if startup discovery failed */
29
+ getError() {
30
+ return this.error;
31
+ }
32
+ /** The master catalog of discovered fields */
33
+ getCatalog() {
34
+ return this.catalog;
35
+ }
36
+ /** Discovery stats (available after catalog build) */
37
+ getStats() {
38
+ return this.stats;
39
+ }
40
+ /** Resolve a human-readable field name to its Jira field ID */
41
+ resolveNameToId(name) {
42
+ return this.nameToId.get(name.toLowerCase()) ?? null;
43
+ }
44
+ /** Look up a catalog field by ID */
45
+ getFieldById(id) {
46
+ return this.idToField.get(id);
47
+ }
48
+ /**
49
+ * Get fields valid for a specific project + issue type, intersected with the master catalog.
50
+ * Returns only writable catalog fields that are also valid for this context.
51
+ * Falls back to the full writable catalog if the createmeta call fails.
52
+ */
53
+ async getContextFields(client, projectKey, issueTypeName) {
54
+ if (!this.ready || this.catalog.length === 0) {
55
+ return [];
56
+ }
57
+ try {
58
+ // Step 1: Get issue types for this project
59
+ const issueTypes = await client.issues.getCreateIssueMetaIssueTypes({
60
+ projectIdOrKey: projectKey,
61
+ });
62
+ const matchingType = (issueTypes.issueTypes || issueTypes.createMetaIssueType || [])
63
+ .find(t => t.name?.toLowerCase() === issueTypeName.toLowerCase());
64
+ if (!matchingType?.id) {
65
+ console.error(`[field-discovery] Issue type "${issueTypeName}" not found in project ${projectKey}`);
66
+ return this.catalog.filter(f => f.writable);
67
+ }
68
+ // Step 2: Get fields for this project + issue type (paginated)
69
+ const contextFieldIds = new Set();
70
+ let startAt = 0;
71
+ const maxResults = 50;
72
+ let hasMore = true;
73
+ while (hasMore) {
74
+ const fieldMeta = await client.issues.getCreateIssueMetaIssueTypeId({
75
+ projectIdOrKey: projectKey,
76
+ issueTypeId: matchingType.id,
77
+ startAt,
78
+ maxResults,
79
+ });
80
+ const fields = fieldMeta.fields || fieldMeta.results || [];
81
+ for (const f of fields) {
82
+ if (f.fieldId)
83
+ contextFieldIds.add(f.fieldId);
84
+ }
85
+ if (fields.length < maxResults) {
86
+ hasMore = false;
87
+ }
88
+ startAt += fields.length;
89
+ }
90
+ // Step 3: Intersect with master catalog (writable fields only)
91
+ return this.catalog.filter(f => f.writable && contextFieldIds.has(f.id));
92
+ }
93
+ catch (err) {
94
+ const msg = err instanceof Error ? err.message : String(err);
95
+ console.error(`[field-discovery] Context field fetch failed for ${projectKey}/${issueTypeName}: ${msg}`);
96
+ // Fall back to all writable catalog fields
97
+ return this.catalog.filter(f => f.writable);
98
+ }
99
+ }
100
+ /**
101
+ * Start discovery in the background. Does not block.
102
+ * Returns a promise that resolves when done (for testing).
103
+ */
104
+ startAsync(client) {
105
+ const promise = this.discover(client);
106
+ // Fire-and-forget for the server — errors are logged and stored
107
+ promise.catch(() => { });
108
+ return promise;
109
+ }
110
+ /**
111
+ * Build the master catalog from Jira's field metadata.
112
+ */
113
+ async discover(client) {
114
+ try {
115
+ console.error('[field-discovery] Starting custom field discovery...');
116
+ const rawFields = await this.fetchAllCustomFields(client);
117
+ console.error(`[field-discovery] Fetched ${rawFields.length} custom fields`);
118
+ const { qualified, stats } = this.filterAndClassify(rawFields);
119
+ console.error(`[field-discovery] ${qualified.length} fields passed filters`);
120
+ const scored = this.scoreFields(qualified);
121
+ const finalCatalog = this.applyCutoff(scored);
122
+ this.catalog = finalCatalog;
123
+ this.stats = { ...stats, catalogSize: finalCatalog.length };
124
+ this.buildIndexes();
125
+ this.ready = true;
126
+ console.error(`[field-discovery] Catalog ready: ${finalCatalog.length} fields`);
127
+ if (stats.undescribedRatio > 0.5) {
128
+ console.error(`[field-discovery] WARNING: ${Math.round(stats.undescribedRatio * 100)}% of on-screen custom fields lack descriptions. ` +
129
+ `Encourage your Jira admin to add descriptions for better AI support.`);
130
+ }
131
+ // Log excluded fields at debug level
132
+ this.logExclusions(rawFields, finalCatalog);
133
+ }
134
+ catch (err) {
135
+ const msg = err instanceof Error ? err.message : String(err);
136
+ this.error = msg;
137
+ console.error(`[field-discovery] Discovery failed: ${msg}`);
138
+ // Don't re-throw — catalog stays empty, fields pass through unfiltered
139
+ }
140
+ }
141
+ /**
142
+ * Fetch all custom fields with metadata via paginated API.
143
+ */
144
+ async fetchAllCustomFields(client) {
145
+ const allFields = [];
146
+ let startAt = 0;
147
+ const maxResults = 50;
148
+ let hasMore = true;
149
+ while (hasMore) {
150
+ const page = await client.issueFields.getFieldsPaginated({
151
+ type: ['custom'],
152
+ expand: ['lastUsed', 'screensCount', 'isLocked'],
153
+ startAt,
154
+ maxResults,
155
+ });
156
+ const values = page.values || [];
157
+ for (const f of values) {
158
+ allFields.push({
159
+ id: f.id,
160
+ name: f.name,
161
+ description: f.description || '',
162
+ isLocked: f.isLocked || false,
163
+ screensCount: f.screensCount || 0,
164
+ lastUsed: f.lastUsed?.value || null,
165
+ lastUsedType: f.lastUsed?.type || 'NO_INFORMATION',
166
+ schemaType: f.schema?.type || '',
167
+ schemaCustom: f.schema?.custom || '',
168
+ schemaItems: f.schema?.items || '',
169
+ });
170
+ }
171
+ if (page.isLast || values.length < maxResults) {
172
+ hasMore = false;
173
+ }
174
+ startAt += values.length;
175
+ }
176
+ return allFields;
177
+ }
178
+ /**
179
+ * Apply qualification filters (ADR-201 §Qualification Criteria).
180
+ */
181
+ filterAndClassify(rawFields) {
182
+ const qualified = [];
183
+ let excludedNoDescription = 0;
184
+ let excludedNoScreens = 0;
185
+ let excludedUnsupportedType = 0;
186
+ let excludedLocked = 0;
187
+ // Count on-screen custom fields without descriptions for the nag ratio
188
+ let onScreenTotal = 0;
189
+ let onScreenNoDescription = 0;
190
+ for (const field of rawFields) {
191
+ // Track nag ratio across all on-screen fields
192
+ if (field.screensCount > 0) {
193
+ onScreenTotal++;
194
+ if (!field.description.trim()) {
195
+ onScreenNoDescription++;
196
+ }
197
+ }
198
+ // Filter: not locked
199
+ if (field.isLocked) {
200
+ excludedLocked++;
201
+ continue;
202
+ }
203
+ // Filter: has description (hard gate)
204
+ if (!field.description.trim()) {
205
+ excludedNoDescription++;
206
+ continue;
207
+ }
208
+ // Filter: on at least 1 screen
209
+ if (field.screensCount < 1) {
210
+ excludedNoScreens++;
211
+ continue;
212
+ }
213
+ // Filter: supported type
214
+ const typeInfo = classifyFieldType(field.schemaType, field.schemaCustom || undefined, field.schemaItems || undefined);
215
+ if (typeInfo.category === 'unsupported') {
216
+ excludedUnsupportedType++;
217
+ continue;
218
+ }
219
+ qualified.push({ ...field, typeInfo });
220
+ }
221
+ const undescribedRatio = onScreenTotal > 0 ? onScreenNoDescription / onScreenTotal : 0;
222
+ return {
223
+ qualified,
224
+ stats: {
225
+ totalCustomFields: rawFields.length,
226
+ excludedNoDescription,
227
+ excludedNoScreens,
228
+ excludedUnsupportedType,
229
+ excludedLocked,
230
+ undescribedRatio,
231
+ },
232
+ };
233
+ }
234
+ /**
235
+ * Score fields by composite score (screens × weight + recency × weight).
236
+ */
237
+ scoreFields(fields) {
238
+ const now = Date.now();
239
+ return fields.map(field => {
240
+ const screenScore = field.screensCount * SCREEN_WEIGHT;
241
+ let recencyScore = 0;
242
+ if (field.lastUsed && field.lastUsedType === 'TRACKED') {
243
+ const lastUsedMs = new Date(field.lastUsed).getTime();
244
+ const daysSinceUse = Math.max(0, (now - lastUsedMs) / (1000 * 60 * 60 * 24));
245
+ // Exponential decay: recently used fields score higher
246
+ recencyScore = Math.exp(-daysSinceUse / RECENCY_HALF_LIFE_DAYS) * RECENCY_WEIGHT;
247
+ }
248
+ const score = screenScore + recencyScore;
249
+ return { ...field, score };
250
+ });
251
+ }
252
+ /**
253
+ * Apply tail-curve cutoff (ADR-201 §Ranking and Tail-Curve Cutoff).
254
+ */
255
+ applyCutoff(fields) {
256
+ if (fields.length === 0)
257
+ return [];
258
+ // Sort descending by score
259
+ const sorted = [...fields].sort((a, b) => b.score - a.score);
260
+ // Check spread ratio
261
+ const maxScore = sorted[0].score;
262
+ const medianIndex = Math.floor(sorted.length / 2);
263
+ const medianScore = sorted[medianIndex].score;
264
+ let cutFields;
265
+ if (medianScore === 0 || maxScore / medianScore < SPREAD_RATIO_THRESHOLD) {
266
+ // Flat distribution — include all
267
+ cutFields = sorted;
268
+ }
269
+ else {
270
+ // Steep distribution — find the knee
271
+ const kneeIndex = this.findKnee(sorted);
272
+ cutFields = sorted.slice(0, kneeIndex + 1);
273
+ }
274
+ // Apply hard cap
275
+ const capped = cutFields.slice(0, HARD_CAP);
276
+ return capped.map(f => ({
277
+ id: f.id,
278
+ name: f.name,
279
+ description: f.description,
280
+ category: f.typeInfo.category,
281
+ writable: f.typeInfo.writable,
282
+ jsonSchema: f.typeInfo.jsonSchema,
283
+ screensCount: f.screensCount,
284
+ lastUsed: f.lastUsed,
285
+ score: Math.round(f.score * 100) / 100,
286
+ }));
287
+ }
288
+ /**
289
+ * Find the knee in a descending-sorted score list.
290
+ * The knee is where the largest relative drop between adjacent scores occurs.
291
+ */
292
+ findKnee(sorted) {
293
+ if (sorted.length <= 2)
294
+ return sorted.length - 1;
295
+ let maxRatio = 0;
296
+ let kneeIndex = sorted.length - 1;
297
+ for (let i = 0; i < sorted.length - 1; i++) {
298
+ const current = sorted[i].score;
299
+ const next = sorted[i + 1].score;
300
+ if (next === 0) {
301
+ kneeIndex = i;
302
+ break;
303
+ }
304
+ const ratio = current / next;
305
+ if (ratio > maxRatio) {
306
+ maxRatio = ratio;
307
+ kneeIndex = i;
308
+ }
309
+ }
310
+ return kneeIndex;
311
+ }
312
+ buildIndexes() {
313
+ this.nameToId.clear();
314
+ this.idToField.clear();
315
+ for (const field of this.catalog) {
316
+ // First wins — catalog is sorted by score descending, so higher-scored field takes precedence
317
+ // when multiple Jira custom fields share the same name
318
+ if (!this.nameToId.has(field.name.toLowerCase())) {
319
+ this.nameToId.set(field.name.toLowerCase(), field.id);
320
+ }
321
+ this.idToField.set(field.id, field);
322
+ }
323
+ }
324
+ logExclusions(rawFields, catalog) {
325
+ const catalogIds = new Set(catalog.map(f => f.id));
326
+ const excluded = rawFields.filter(f => !catalogIds.has(f.id));
327
+ for (const field of excluded) {
328
+ const reasons = [];
329
+ if (field.isLocked)
330
+ reasons.push('locked');
331
+ if (!field.description.trim())
332
+ reasons.push('no description');
333
+ if (field.screensCount < 1)
334
+ reasons.push('not on any screen');
335
+ const typeInfo = classifyFieldType(field.schemaType, field.schemaCustom || undefined, field.schemaItems || undefined);
336
+ if (typeInfo.category === 'unsupported')
337
+ reasons.push(`unsupported type (${field.schemaCustom || field.schemaType})`);
338
+ if (reasons.length === 0)
339
+ reasons.push('below cutoff');
340
+ console.error(`[field-discovery] Excluded: ${field.name} (${field.id}) — ${reasons.join(', ')}`);
341
+ }
342
+ }
343
+ }
344
+ // ── Singleton ──────────────────────────────────────────────────────────
345
+ /** Singleton instance — lives for the MCP server process lifetime */
346
+ export const fieldDiscovery = new FieldDiscovery();
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Maps Jira custom field schema types to JSON Schema representations (ADR-201 §Schema Type Mapping).
3
+ *
4
+ * v1 supported types: string, number, date, single-select, multi-select, user picker, labels, URL.
5
+ * Cascading selects and exotic types (assets, objects) are unsupported for writes.
6
+ */
7
+ /** Suffix after the last ':' in the Jira custom field type URI */
8
+ const CUSTOM_TYPE_SUFFIX = {
9
+ textfield: 'string',
10
+ textarea: 'string',
11
+ float: 'number',
12
+ datepicker: 'date',
13
+ datetime: 'datetime',
14
+ select: 'single-select',
15
+ radiobuttons: 'single-select',
16
+ multiselect: 'multi-select',
17
+ multicheckboxes: 'multi-select',
18
+ userpicker: 'user',
19
+ multiuserpicker: 'multi-user',
20
+ labels: 'labels',
21
+ url: 'url',
22
+ // Unsupported — recognized so we can label them explicitly
23
+ cascadingselect: 'unsupported',
24
+ };
25
+ /** Fallback mapping from the schema `type` field when the custom URI isn't recognized */
26
+ const SCHEMA_TYPE_FALLBACK = {
27
+ string: 'string',
28
+ number: 'number',
29
+ date: 'date',
30
+ datetime: 'datetime',
31
+ user: 'user',
32
+ option: 'single-select',
33
+ };
34
+ const JSON_SCHEMAS = {
35
+ string: { type: 'string' },
36
+ number: { type: 'number' },
37
+ date: { type: 'string', format: 'date' },
38
+ datetime: { type: 'string', format: 'date-time' },
39
+ 'single-select': { type: 'string', description: 'Select from allowed values' },
40
+ 'multi-select': { type: 'array', items: { type: 'string', description: 'Select from allowed values' } },
41
+ user: { type: 'string', description: 'Atlassian accountId' },
42
+ 'multi-user': { type: 'array', items: { type: 'string', description: 'Atlassian accountId' } },
43
+ labels: { type: 'array', items: { type: 'string' } },
44
+ url: { type: 'string', format: 'uri' },
45
+ unsupported: {},
46
+ };
47
+ /**
48
+ * Classify a Jira field schema into our type system.
49
+ *
50
+ * @param schemaType - The `schema.type` value (e.g. "string", "number", "array", "option")
51
+ * @param customUri - The `schema.custom` URI (e.g. "com.atlassian.jira.plugin.system.customfieldtypes:float")
52
+ * @param items - The `schema.items` value for array types
53
+ */
54
+ export function classifyFieldType(schemaType, customUri, items) {
55
+ // Try the custom URI suffix first — most specific signal
56
+ if (customUri) {
57
+ const suffix = customUri.split(':').pop() || '';
58
+ const category = CUSTOM_TYPE_SUFFIX[suffix];
59
+ if (category) {
60
+ return {
61
+ category,
62
+ writable: category !== 'unsupported',
63
+ jsonSchema: JSON_SCHEMAS[category],
64
+ };
65
+ }
66
+ }
67
+ // For array types, check the items type
68
+ if (schemaType === 'array' && items) {
69
+ if (items === 'option')
70
+ return typeInfo('multi-select');
71
+ if (items === 'user')
72
+ return typeInfo('multi-user');
73
+ if (items === 'string')
74
+ return typeInfo('labels');
75
+ }
76
+ // Fall back to schema type
77
+ const fallback = SCHEMA_TYPE_FALLBACK[schemaType];
78
+ if (fallback) {
79
+ return typeInfo(fallback);
80
+ }
81
+ // Unrecognized — treat as unsupported (read-only on get, not suggested for writes)
82
+ return typeInfo('unsupported');
83
+ }
84
+ function typeInfo(category) {
85
+ return {
86
+ category,
87
+ writable: category !== 'unsupported',
88
+ jsonSchema: JSON_SCHEMAS[category],
89
+ };
90
+ }
91
+ /** Human-readable label for a field category */
92
+ export function categoryLabel(category) {
93
+ switch (category) {
94
+ case 'string': return 'text';
95
+ case 'number': return 'number';
96
+ case 'date': return 'date';
97
+ case 'datetime': return 'date-time';
98
+ case 'single-select': return 'select';
99
+ case 'multi-select': return 'multi-select';
100
+ case 'user': return 'user';
101
+ case 'multi-user': return 'multi-user';
102
+ case 'labels': return 'labels';
103
+ case 'url': return 'URL';
104
+ case 'unsupported': return 'unsupported';
105
+ }
106
+ }