@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
|
@@ -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
|
{
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handler for manage_jira_media tool.
|
|
3
|
+
* See ADR-211: Attachment and Workspace Management.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'node:fs/promises';
|
|
6
|
+
import { mediaNextSteps } from '../utils/next-steps.js';
|
|
7
|
+
import { ensureWorkspaceDir, formatSize, resolveWorkspacePath, ensureParentDir, verifyPathSafety, sanitizeFilename, } from '../workspace/index.js';
|
|
8
|
+
export async function handleMediaRequest(client, args) {
|
|
9
|
+
switch (args.operation) {
|
|
10
|
+
case 'list': {
|
|
11
|
+
if (!args.issueKey) {
|
|
12
|
+
return { content: [{ type: 'text', text: 'issueKey is required for list operation' }], isError: true };
|
|
13
|
+
}
|
|
14
|
+
const attachments = await client.getIssueAttachments(args.issueKey);
|
|
15
|
+
if (attachments.length === 0) {
|
|
16
|
+
let text = `No attachments on ${args.issueKey}.`;
|
|
17
|
+
text += mediaNextSteps('list', { issueKey: args.issueKey });
|
|
18
|
+
return { content: [{ type: 'text', text }] };
|
|
19
|
+
}
|
|
20
|
+
const lines = attachments.map(a => `- ${a.filename} | ${a.mimeType} | ${formatSize(a.size)} | id:${a.id} | ${a.author} | ${a.created}`);
|
|
21
|
+
let text = `Attachments on ${args.issueKey} (${attachments.length}):\n${lines.join('\n')}`;
|
|
22
|
+
text += mediaNextSteps('list', { issueKey: args.issueKey });
|
|
23
|
+
return { content: [{ type: 'text', text }] };
|
|
24
|
+
}
|
|
25
|
+
case 'upload': {
|
|
26
|
+
if (!args.issueKey || !args.filename || !args.mediaType) {
|
|
27
|
+
return {
|
|
28
|
+
content: [{ type: 'text', text: 'issueKey, filename, and mediaType are required for upload' }],
|
|
29
|
+
isError: true,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
let buffer;
|
|
33
|
+
if (args.workspaceFile) {
|
|
34
|
+
const filePath = resolveWorkspacePath(args.workspaceFile);
|
|
35
|
+
await verifyPathSafety(filePath);
|
|
36
|
+
try {
|
|
37
|
+
buffer = await fs.readFile(filePath);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return { content: [{ type: 'text', text: `Workspace file not found: ${args.workspaceFile}` }], isError: true };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else if (args.content) {
|
|
44
|
+
buffer = Buffer.from(args.content, 'base64');
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: 'text', text: 'Either content (base64) or workspaceFile is required for upload' }],
|
|
49
|
+
isError: true,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const safeFilename = sanitizeFilename(args.filename);
|
|
53
|
+
const attachment = await client.uploadAttachment(args.issueKey, safeFilename, buffer, args.mediaType);
|
|
54
|
+
let text = `Uploaded: ${attachment.filename} | ${attachment.mimeType} | ${formatSize(attachment.size)} | id:${attachment.id}`;
|
|
55
|
+
text += mediaNextSteps('upload', { issueKey: args.issueKey });
|
|
56
|
+
return { content: [{ type: 'text', text }] };
|
|
57
|
+
}
|
|
58
|
+
case 'delete': {
|
|
59
|
+
if (!args.attachmentId) {
|
|
60
|
+
return { content: [{ type: 'text', text: 'attachmentId is required for delete operation' }], isError: true };
|
|
61
|
+
}
|
|
62
|
+
await client.deleteAttachment(args.attachmentId);
|
|
63
|
+
return { content: [{ type: 'text', text: `Permanently deleted attachment ${args.attachmentId} from Jira. This cannot be undone.` }] };
|
|
64
|
+
}
|
|
65
|
+
case 'view': {
|
|
66
|
+
if (!args.attachmentId) {
|
|
67
|
+
return { content: [{ type: 'text', text: 'attachmentId is required for view operation' }], isError: true };
|
|
68
|
+
}
|
|
69
|
+
const info = await client.getAttachmentInfo(args.attachmentId);
|
|
70
|
+
if (!info.mimeType.startsWith('image/')) {
|
|
71
|
+
return {
|
|
72
|
+
content: [{
|
|
73
|
+
type: 'text',
|
|
74
|
+
text: `${info.filename} | ${info.mimeType} | ${formatSize(info.size)}\n\nNot an image — cannot display inline. Use download to fetch raw content.`,
|
|
75
|
+
}],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
79
|
+
if (info.size > MAX_IMAGE_SIZE) {
|
|
80
|
+
return {
|
|
81
|
+
content: [{
|
|
82
|
+
type: 'text',
|
|
83
|
+
text: `${info.filename} | ${info.mimeType} | ${formatSize(info.size)}\n\nImage too large to display inline (${(info.size / 1024 / 1024).toFixed(1)}MB, max 5MB). Use download instead.`,
|
|
84
|
+
}],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const bytes = await client.downloadAttachment(args.attachmentId);
|
|
88
|
+
return {
|
|
89
|
+
content: [
|
|
90
|
+
{ type: 'text', text: `${info.filename} | ${info.mimeType}` },
|
|
91
|
+
{ type: 'image', data: bytes.toString('base64'), mimeType: info.mimeType },
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
case 'get_info': {
|
|
96
|
+
if (!args.attachmentId) {
|
|
97
|
+
return { content: [{ type: 'text', text: 'attachmentId is required for get_info operation' }], isError: true };
|
|
98
|
+
}
|
|
99
|
+
const attachInfo = await client.getAttachmentInfo(args.attachmentId);
|
|
100
|
+
return {
|
|
101
|
+
content: [{
|
|
102
|
+
type: 'text',
|
|
103
|
+
text: `${attachInfo.filename} | ${attachInfo.mimeType} | ${formatSize(attachInfo.size)} | id:${attachInfo.id} | ${attachInfo.author} | ${attachInfo.created}`,
|
|
104
|
+
}],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
case 'download': {
|
|
108
|
+
if (!args.attachmentId) {
|
|
109
|
+
return { content: [{ type: 'text', text: 'attachmentId is required for download operation' }], isError: true };
|
|
110
|
+
}
|
|
111
|
+
const dlInfo = await client.getAttachmentInfo(args.attachmentId);
|
|
112
|
+
const dlBytes = await client.downloadAttachment(args.attachmentId);
|
|
113
|
+
const status = await ensureWorkspaceDir();
|
|
114
|
+
if (!status.valid) {
|
|
115
|
+
return { content: [{ type: 'text', text: `Workspace invalid: ${status.warning}` }], isError: true };
|
|
116
|
+
}
|
|
117
|
+
const dlFilename = args.filename || sanitizeFilename(dlInfo.filename);
|
|
118
|
+
const dlPath = resolveWorkspacePath(dlFilename);
|
|
119
|
+
await verifyPathSafety(dlPath);
|
|
120
|
+
await ensureParentDir(dlPath);
|
|
121
|
+
await fs.writeFile(dlPath, dlBytes);
|
|
122
|
+
let text = `Downloaded: ${dlFilename} | ${dlInfo.mimeType} | ${formatSize(dlBytes.length)}\nPath: ${dlPath}`;
|
|
123
|
+
text += `\n\nUse manage_workspace read or manage_jira_media upload with workspaceFile:"${dlFilename}" to use it.`;
|
|
124
|
+
text += mediaNextSteps('download', {});
|
|
125
|
+
return { content: [{ type: 'text', text }] };
|
|
126
|
+
}
|
|
127
|
+
default:
|
|
128
|
+
return { content: [{ type: 'text', text: `Unknown media operation: ${args.operation}` }], isError: true };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -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
|
};
|