@aaronsb/jira-cloud-mcp 0.6.1 → 0.7.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.
@@ -71,12 +71,9 @@ export class FieldDiscovery {
71
71
  return [];
72
72
  }
73
73
  try {
74
- // Step 1: Get issue types for this project
75
- const issueTypes = await client.issues.getCreateIssueMetaIssueTypes({
76
- projectIdOrKey: projectKey,
77
- });
78
- const matchingType = (issueTypes.issueTypes || issueTypes.createMetaIssueType || [])
79
- .find(t => t.name?.toLowerCase() === issueTypeName.toLowerCase());
74
+ // Step 1: Get issue types (uses shared cache)
75
+ const issueTypes = await this.getIssueTypes(client, projectKey);
76
+ const matchingType = issueTypes.find(t => t.name.toLowerCase() === issueTypeName.toLowerCase());
80
77
  if (!matchingType?.id) {
81
78
  console.error(`[field-discovery] Issue type "${issueTypeName}" not found in project ${projectKey}`);
82
79
  return this.catalog.filter(f => f.writable);
@@ -113,6 +110,92 @@ export class FieldDiscovery {
113
110
  return this.catalog.filter(f => f.writable);
114
111
  }
115
112
  }
113
+ // ── Required Fields Cache ────────────────────────────────────────────
114
+ requiredFieldsCache = new Map();
115
+ issueTypesCache = new Map();
116
+ /** Get issue types available for a project (lazy cached). */
117
+ async getIssueTypes(client, projectKey) {
118
+ const cacheKey = projectKey.toUpperCase();
119
+ const cached = this.issueTypesCache.get(cacheKey);
120
+ if (cached)
121
+ return cached;
122
+ try {
123
+ const result = await client.issues.getCreateIssueMetaIssueTypes({
124
+ projectIdOrKey: projectKey,
125
+ });
126
+ const types = (result.issueTypes || result.createMetaIssueType || [])
127
+ .filter((t) => t.id && t.name)
128
+ .map((t) => ({
129
+ id: t.id,
130
+ name: t.name,
131
+ subtask: t.subtask ?? false,
132
+ }));
133
+ this.issueTypesCache.set(cacheKey, types);
134
+ return types;
135
+ }
136
+ catch (err) {
137
+ console.error(`[field-discovery] Issue type fetch failed for ${projectKey}: ${err instanceof Error ? err.message : err}`);
138
+ return [];
139
+ }
140
+ }
141
+ /** Get required fields for a project + issue type combination (lazy cached). */
142
+ async getRequiredFields(client, projectKey, issueTypeName) {
143
+ const cacheKey = `${projectKey}:${issueTypeName}`.toLowerCase();
144
+ const cached = this.requiredFieldsCache.get(cacheKey);
145
+ if (cached)
146
+ return cached;
147
+ try {
148
+ const issueTypes = await this.getIssueTypes(client, projectKey);
149
+ const matchingType = issueTypes.find(t => t.name.toLowerCase() === issueTypeName.toLowerCase());
150
+ if (!matchingType)
151
+ return [];
152
+ const required = [];
153
+ let startAt = 0;
154
+ const maxResults = 50;
155
+ let hasMore = true;
156
+ while (hasMore) {
157
+ const fieldMeta = await client.issues.getCreateIssueMetaIssueTypeId({
158
+ projectIdOrKey: projectKey,
159
+ issueTypeId: matchingType.id,
160
+ startAt,
161
+ maxResults,
162
+ });
163
+ const fields = (fieldMeta.fields || fieldMeta.results || []);
164
+ for (const f of fields) {
165
+ if (f.required && !f.hasDefaultValue) {
166
+ const info = {
167
+ fieldId: f.fieldId,
168
+ name: f.name,
169
+ schemaType: f.schema?.type ?? 'unknown',
170
+ };
171
+ if (f.allowedValues && Array.isArray(f.allowedValues)) {
172
+ info.allowedValues = f.allowedValues
173
+ .slice(0, 20)
174
+ .map((v) => v.name ?? v.value ?? String(v));
175
+ }
176
+ required.push(info);
177
+ }
178
+ }
179
+ if (fields.length < maxResults)
180
+ hasMore = false;
181
+ startAt += fields.length;
182
+ }
183
+ this.requiredFieldsCache.set(cacheKey, required);
184
+ return required;
185
+ }
186
+ catch (err) {
187
+ console.error(`[field-discovery] Required fields fetch failed for ${projectKey}/${issueTypeName}: ${err instanceof Error ? err.message : err}`);
188
+ return [];
189
+ }
190
+ }
191
+ /** Clear required fields cache for a project (on 400 errors, cache may be stale). */
192
+ invalidateRequiredFields(projectKey) {
193
+ for (const key of this.requiredFieldsCache.keys()) {
194
+ if (key.startsWith(projectKey.toLowerCase() + ':')) {
195
+ this.requiredFieldsCache.delete(key);
196
+ }
197
+ }
198
+ }
116
199
  /**
117
200
  * Start discovery in the background. Does not block.
118
201
  * Returns a promise that resolves when done (for testing).
@@ -31,9 +31,9 @@ export class JiraClient {
31
31
  sprint: config.customFields?.sprint ?? null, // Discovered at runtime via field-discovery
32
32
  };
33
33
  }
34
- /** Update sprint field ID after runtime discovery */
35
- setSprintFieldId(fieldId) {
36
- this.customFields.sprint = fieldId;
34
+ /** Update a custom field ID after runtime discovery */
35
+ setCustomFieldId(logicalName, fieldId) {
36
+ this.customFields[logicalName] = fieldId;
37
37
  }
38
38
  /** Standard Jira fields that require ADF format in v3 API */
39
39
  static ADF_FIELDS = new Set(['environment']);
@@ -1,4 +1,5 @@
1
1
  import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
+ import { fieldDiscovery } from '../client/field-discovery.js';
2
3
  import { MarkdownRenderer } from '../mcp/markdown-renderer.js';
3
4
  import { projectNextSteps } from '../utils/next-steps.js';
4
5
  import { normalizeArgs } from '../utils/normalize-args.js';
@@ -70,6 +71,16 @@ async function handleGetProject(jiraClient, args) {
70
71
  description: project.description || undefined,
71
72
  lead: project.lead || undefined,
72
73
  };
74
+ // Fetch issue types (always — essential context for creating issues)
75
+ try {
76
+ const issueTypes = await fieldDiscovery.getIssueTypes(jiraClient.v3Client, projectKey);
77
+ if (issueTypes.length > 0) {
78
+ projectData.issueTypes = issueTypes.map(t => ({ name: t.name, subtask: t.subtask }));
79
+ }
80
+ }
81
+ catch {
82
+ // Non-fatal — continue without issue types
83
+ }
73
84
  // If status counts are requested, get them
74
85
  if (includeStatusCounts) {
75
86
  try {
@@ -125,6 +136,7 @@ async function handleGetProject(jiraClient, args) {
125
136
  name: projectData.name,
126
137
  description: projectData.description,
127
138
  lead: projectData.lead,
139
+ issueTypes: projectData.issueTypes,
128
140
  statusCounts: projectData.statusCounts,
129
141
  boards: projectData.boards,
130
142
  recentIssues: projectData.recentIssues,
package/build/index.js CHANGED
@@ -84,10 +84,13 @@ class JiraServer {
84
84
  this.setupHandlers();
85
85
  // Start async field discovery (non-blocking)
86
86
  fieldDiscovery.startAsync(this.jiraClient.v3Client).then(() => {
87
- const sprintId = fieldDiscovery.getWellKnownFieldId('sprint');
88
- if (sprintId) {
89
- this.jiraClient.setSprintFieldId(sprintId);
90
- console.error(`[jira-cloud] Sprint field: ${sprintId}`);
87
+ const wirable = ['sprint', 'storyPoints', 'startDate'];
88
+ for (const name of wirable) {
89
+ const fieldId = fieldDiscovery.getWellKnownFieldId(name);
90
+ if (fieldId) {
91
+ this.jiraClient.setCustomFieldId(name, fieldId);
92
+ console.error(`[jira-cloud] ${name} field: ${fieldId}`);
93
+ }
91
94
  }
92
95
  }).catch(() => { });
93
96
  // CloudId discovery happens in run() before server connects — must complete
@@ -220,6 +223,50 @@ class JiraServer {
220
223
  isError: true,
221
224
  };
222
225
  }
226
+ if (status === 400) {
227
+ const fieldErrors = error?.response?.data?.errors;
228
+ const errorMessages = error?.response?.data?.errorMessages;
229
+ const lines = ['**Jira rejected this request:**'];
230
+ if (errorMessages?.length > 0) {
231
+ lines.push(...errorMessages.map((m) => `- ${m}`));
232
+ }
233
+ if (fieldErrors && Object.keys(fieldErrors).length > 0) {
234
+ lines.push('', '**Field errors:**');
235
+ for (const [field, msg] of Object.entries(fieldErrors)) {
236
+ lines.push(`- \`${field}\`: ${msg}`);
237
+ }
238
+ }
239
+ // On create failures, invalidate cache and append required fields guidance
240
+ const reqArgs = request.params.arguments;
241
+ if (reqArgs?.operation === 'create' && reqArgs?.projectKey) {
242
+ const pKey = reqArgs.projectKey;
243
+ fieldDiscovery.invalidateRequiredFields(pKey);
244
+ const iType = reqArgs.issueType;
245
+ try {
246
+ // Show valid issue types
247
+ const issueTypes = await fieldDiscovery.getIssueTypes(this.jiraClient.v3Client, pKey);
248
+ if (issueTypes.length > 0) {
249
+ lines.push('', `**Valid issue types for ${pKey}:** ${issueTypes.map(t => t.name).join(', ')}`);
250
+ }
251
+ // Show required fields for the requested type
252
+ if (iType) {
253
+ const required = await fieldDiscovery.getRequiredFields(this.jiraClient.v3Client, pKey, iType);
254
+ if (required.length > 0) {
255
+ lines.push(`**Required fields for ${pKey}/${iType}:** ${required.map(f => {
256
+ const vals = f.allowedValues ? ` (${f.schemaType}: ${f.allowedValues.slice(0, 5).join(', ')}${f.allowedValues.length > 5 ? '...' : ''})` : '';
257
+ return f.name + vals;
258
+ }).join(', ')}`);
259
+ }
260
+ }
261
+ }
262
+ catch { /* best-effort */ }
263
+ lines.push('', '*Tip: Use `manage_jira_project get` to see valid issue types before creating.*');
264
+ }
265
+ return {
266
+ content: [{ type: 'text', text: lines.join('\n') }],
267
+ isError: true,
268
+ };
269
+ }
223
270
  if (status === 404) {
224
271
  return {
225
272
  content: [{
@@ -272,6 +272,17 @@ export function renderProject(project) {
272
272
  lines.push('');
273
273
  lines.push(truncate(stripHtml(project.description), 200));
274
274
  }
275
+ // Issue types
276
+ if (project.issueTypes && project.issueTypes.length > 0) {
277
+ const regular = project.issueTypes.filter(t => !t.subtask).map(t => t.name);
278
+ const subtasks = project.issueTypes.filter(t => t.subtask).map(t => t.name);
279
+ const parts = [...regular];
280
+ if (subtasks.length > 0) {
281
+ parts.push(...subtasks.map(n => `${n} (subtask)`));
282
+ }
283
+ lines.push('');
284
+ lines.push(`**Issue Types:** ${parts.join(', ')}`);
285
+ }
275
286
  // Status counts
276
287
  if (project.statusCounts && Object.keys(project.statusCounts).length > 0) {
277
288
  lines.push('');
@@ -169,7 +169,7 @@ export const toolSchemas = {
169
169
  },
170
170
  issueType: {
171
171
  type: 'string',
172
- description: 'Issue type (e.g., Story, Bug, Task). Required for create.',
172
+ description: 'Issue type name — must match project configuration (e.g., Story, Bug, Task, Feature). Use manage_jira_project get to discover valid types. Required for create.',
173
173
  },
174
174
  priority: {
175
175
  type: 'string',
@@ -108,7 +108,7 @@ export function projectNextSteps(operation, projectKey) {
108
108
  steps.push({ description: 'Get project details', tool: 'manage_jira_project', example: { operation: 'get', projectKey: '<key>' } }, { description: 'Search issues in a project', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql: `project = <key>` } });
109
109
  break;
110
110
  case 'get':
111
- steps.push({ description: 'Search issues in this project', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql: `project = ${projectKey}` } }, { description: 'View project boards', tool: 'manage_jira_board', example: { operation: 'list' } }, { description: `Read jira://projects/${projectKey}/overview for additional context` });
111
+ steps.push({ description: 'Create an issue in this project (use issue types shown above)', tool: 'manage_jira_issue', example: { operation: 'create', projectKey, summary: '<title>', issueType: '<type from list above>' } }, { description: 'Search issues in this project', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql: `project = ${projectKey}` } }, { description: 'View project boards', tool: 'manage_jira_board', example: { operation: 'list' } });
112
112
  break;
113
113
  }
114
114
  return steps.length > 0 ? formatSteps(steps) : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "mcpName": "io.github.aaronsb/jira-cloud",
5
5
  "description": "Model Context Protocol (MCP) server for Jira Cloud - enables AI assistants to interact with Jira",
6
6
  "type": "module",