@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,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handler for manage_workspace tool.
|
|
3
|
+
* See ADR-211: Attachment and Workspace Management.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'node:fs/promises';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import { ensureWorkspaceDir, formatSize, resolveWorkspacePath, ensureParentDir, verifyPathSafety, } from '../workspace/index.js';
|
|
8
|
+
const TEXT_INLINE_LIMIT = 100 * 1024; // 100KB
|
|
9
|
+
const IMAGE_INLINE_LIMIT = 5 * 1024 * 1024; // 5MB
|
|
10
|
+
const IMAGE_EXTENSIONS = {
|
|
11
|
+
'.png': 'image/png',
|
|
12
|
+
'.jpg': 'image/jpeg',
|
|
13
|
+
'.jpeg': 'image/jpeg',
|
|
14
|
+
'.gif': 'image/gif',
|
|
15
|
+
'.webp': 'image/webp',
|
|
16
|
+
'.svg': 'image/svg+xml',
|
|
17
|
+
'.bmp': 'image/bmp',
|
|
18
|
+
'.ico': 'image/x-icon',
|
|
19
|
+
};
|
|
20
|
+
const TEXT_EXTENSIONS = new Set([
|
|
21
|
+
'.txt', '.md', '.json', '.xml', '.csv', '.html', '.htm',
|
|
22
|
+
'.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.log',
|
|
23
|
+
'.js', '.ts', '.py', '.rb', '.sh', '.bash', '.zsh',
|
|
24
|
+
'.css', '.scss', '.less', '.svg',
|
|
25
|
+
]);
|
|
26
|
+
export async function handleWorkspaceRequest(args) {
|
|
27
|
+
switch (args.operation) {
|
|
28
|
+
case 'list':
|
|
29
|
+
return handleList();
|
|
30
|
+
case 'read':
|
|
31
|
+
return handleRead(args);
|
|
32
|
+
case 'write':
|
|
33
|
+
return handleWrite(args);
|
|
34
|
+
case 'delete':
|
|
35
|
+
return handleDelete(args);
|
|
36
|
+
case 'mkdir':
|
|
37
|
+
return handleMkdir(args);
|
|
38
|
+
case 'move':
|
|
39
|
+
return handleMove(args);
|
|
40
|
+
default:
|
|
41
|
+
return { content: [{ type: 'text', text: `Unknown workspace operation: ${args.operation}` }], isError: true };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function handleList() {
|
|
45
|
+
const status = await ensureWorkspaceDir();
|
|
46
|
+
if (!status.valid) {
|
|
47
|
+
return { content: [{ type: 'text', text: `Workspace invalid: ${status.warning}` }], isError: true };
|
|
48
|
+
}
|
|
49
|
+
const lines = [`Workspace: ${status.path}\n`];
|
|
50
|
+
await listRecursive(status.path, status.path, lines, 0);
|
|
51
|
+
if (lines.length === 1) {
|
|
52
|
+
return { content: [{ type: 'text', text: `Workspace: ${status.path}\n\n(empty — no files staged)` }] };
|
|
53
|
+
}
|
|
54
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
55
|
+
}
|
|
56
|
+
const MAX_LIST_DEPTH = 10;
|
|
57
|
+
async function listRecursive(rootDir, dir, lines, depth) {
|
|
58
|
+
if (depth >= MAX_LIST_DEPTH) {
|
|
59
|
+
lines.push(`${' '.repeat(depth + 1)}(truncated — max depth ${MAX_LIST_DEPTH})`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
let entries;
|
|
63
|
+
try {
|
|
64
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const indent = ' '.repeat(depth + 1);
|
|
70
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
71
|
+
if (entry.isSymbolicLink())
|
|
72
|
+
continue; // skip symlinks to prevent loops
|
|
73
|
+
try {
|
|
74
|
+
const fullPath = path.join(dir, entry.name);
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
lines.push(`${indent}${entry.name}/`);
|
|
77
|
+
await listRecursive(rootDir, fullPath, lines, depth + 1);
|
|
78
|
+
}
|
|
79
|
+
else if (entry.isFile()) {
|
|
80
|
+
const stat = await fs.stat(fullPath);
|
|
81
|
+
lines.push(`${indent}${entry.name} (${formatSize(stat.size)}, ${stat.mtime.toISOString().slice(0, 16)})`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Skip entries we can't stat
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function handleRead(args) {
|
|
90
|
+
if (!args.filename) {
|
|
91
|
+
return { content: [{ type: 'text', text: 'filename is required for read operation' }], isError: true };
|
|
92
|
+
}
|
|
93
|
+
const filePath = resolveWorkspacePath(args.filename);
|
|
94
|
+
await verifyPathSafety(filePath);
|
|
95
|
+
let stat;
|
|
96
|
+
try {
|
|
97
|
+
stat = await fs.stat(filePath);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { content: [{ type: 'text', text: `File not found in workspace: ${args.filename}` }], isError: true };
|
|
101
|
+
}
|
|
102
|
+
const ext = path.extname(args.filename).toLowerCase();
|
|
103
|
+
const isText = TEXT_EXTENSIONS.has(ext);
|
|
104
|
+
const imageMime = IMAGE_EXTENSIONS[ext];
|
|
105
|
+
// Inline text
|
|
106
|
+
if (isText && stat.size <= TEXT_INLINE_LIMIT) {
|
|
107
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
108
|
+
return { content: [{ type: 'text', text: `File: ${args.filename} (${formatSize(stat.size)})\nPath: ${filePath}\n\n${content}` }] };
|
|
109
|
+
}
|
|
110
|
+
// Inline image
|
|
111
|
+
if (imageMime && stat.size <= IMAGE_INLINE_LIMIT) {
|
|
112
|
+
const bytes = await fs.readFile(filePath);
|
|
113
|
+
return {
|
|
114
|
+
content: [
|
|
115
|
+
{ type: 'text', text: `File: ${args.filename} | ${formatSize(stat.size)}\nPath: ${filePath}` },
|
|
116
|
+
{ type: 'image', data: bytes.toString('base64'), mimeType: imageMime },
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// Too large or unsupported — path reference only
|
|
121
|
+
const label = imageMime ? 'image (too large to display inline)' : isText ? 'text' : 'binary';
|
|
122
|
+
return {
|
|
123
|
+
content: [{
|
|
124
|
+
type: 'text',
|
|
125
|
+
text: `File: ${args.filename} | ${formatSize(stat.size)} | ${label}\nPath: ${filePath}\n\nUse manage_jira_media upload with workspaceFile to upload, or manage_workspace delete to remove.`,
|
|
126
|
+
}],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
async function handleWrite(args) {
|
|
130
|
+
if (!args.filename) {
|
|
131
|
+
return { content: [{ type: 'text', text: 'filename is required for write operation' }], isError: true };
|
|
132
|
+
}
|
|
133
|
+
if (!args.content) {
|
|
134
|
+
return { content: [{ type: 'text', text: 'content (base64-encoded) is required for write operation' }], isError: true };
|
|
135
|
+
}
|
|
136
|
+
const status = await ensureWorkspaceDir();
|
|
137
|
+
if (!status.valid) {
|
|
138
|
+
return { content: [{ type: 'text', text: `Workspace invalid: ${status.warning}` }], isError: true };
|
|
139
|
+
}
|
|
140
|
+
const filePath = resolveWorkspacePath(args.filename);
|
|
141
|
+
await verifyPathSafety(filePath);
|
|
142
|
+
await ensureParentDir(filePath);
|
|
143
|
+
const buffer = Buffer.from(args.content, 'base64');
|
|
144
|
+
await fs.writeFile(filePath, buffer);
|
|
145
|
+
return {
|
|
146
|
+
content: [{
|
|
147
|
+
type: 'text',
|
|
148
|
+
text: `Written: ${args.filename} (${formatSize(buffer.length)})\nPath: ${filePath}`,
|
|
149
|
+
}],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
async function handleDelete(args) {
|
|
153
|
+
if (!args.filename) {
|
|
154
|
+
return { content: [{ type: 'text', text: 'filename is required for delete operation' }], isError: true };
|
|
155
|
+
}
|
|
156
|
+
const filePath = resolveWorkspacePath(args.filename);
|
|
157
|
+
await verifyPathSafety(filePath);
|
|
158
|
+
try {
|
|
159
|
+
const stat = await fs.stat(filePath);
|
|
160
|
+
if (stat.isDirectory()) {
|
|
161
|
+
await fs.rm(filePath, { recursive: true });
|
|
162
|
+
return { content: [{ type: 'text', text: `Deleted local directory: ${args.filename} (Jira attachments unaffected)` }] };
|
|
163
|
+
}
|
|
164
|
+
await fs.unlink(filePath);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return { content: [{ type: 'text', text: `File not found in workspace: ${args.filename}` }], isError: true };
|
|
168
|
+
}
|
|
169
|
+
return { content: [{ type: 'text', text: `Deleted local file: ${args.filename} (Jira attachments unaffected)` }] };
|
|
170
|
+
}
|
|
171
|
+
async function handleMkdir(args) {
|
|
172
|
+
if (!args.filename) {
|
|
173
|
+
return { content: [{ type: 'text', text: 'filename (directory path) is required for mkdir operation' }], isError: true };
|
|
174
|
+
}
|
|
175
|
+
const status = await ensureWorkspaceDir();
|
|
176
|
+
if (!status.valid) {
|
|
177
|
+
return { content: [{ type: 'text', text: `Workspace invalid: ${status.warning}` }], isError: true };
|
|
178
|
+
}
|
|
179
|
+
const dirPath = resolveWorkspacePath(args.filename);
|
|
180
|
+
await verifyPathSafety(dirPath);
|
|
181
|
+
await fs.mkdir(dirPath, { recursive: true, mode: 0o755 });
|
|
182
|
+
return {
|
|
183
|
+
content: [{
|
|
184
|
+
type: 'text',
|
|
185
|
+
text: `Created: ${args.filename}/\nPath: ${dirPath}`,
|
|
186
|
+
}],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
async function handleMove(args) {
|
|
190
|
+
if (!args.filename) {
|
|
191
|
+
return { content: [{ type: 'text', text: 'filename (source path) is required for move operation' }], isError: true };
|
|
192
|
+
}
|
|
193
|
+
if (!args.destination) {
|
|
194
|
+
return { content: [{ type: 'text', text: 'destination path is required for move operation' }], isError: true };
|
|
195
|
+
}
|
|
196
|
+
const srcPath = resolveWorkspacePath(args.filename);
|
|
197
|
+
await verifyPathSafety(srcPath);
|
|
198
|
+
const destPath = resolveWorkspacePath(args.destination);
|
|
199
|
+
await verifyPathSafety(destPath);
|
|
200
|
+
try {
|
|
201
|
+
await fs.stat(srcPath);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return { content: [{ type: 'text', text: `Source not found in workspace: ${args.filename}` }], isError: true };
|
|
205
|
+
}
|
|
206
|
+
await ensureParentDir(destPath);
|
|
207
|
+
await fs.rename(srcPath, destPath);
|
|
208
|
+
return {
|
|
209
|
+
content: [{
|
|
210
|
+
type: 'text',
|
|
211
|
+
text: `Moved: ${args.filename} -> ${args.destination}\nPath: ${destPath}`,
|
|
212
|
+
}],
|
|
213
|
+
};
|
|
214
|
+
}
|
package/build/index.js
CHANGED
|
@@ -11,14 +11,18 @@ import { handleAnalysisRequest } from './handlers/analysis-handler.js';
|
|
|
11
11
|
import { handleBoardRequest } from './handlers/board-handlers.js';
|
|
12
12
|
import { handleFilterRequest } from './handlers/filter-handlers.js';
|
|
13
13
|
import { handleIssueRequest } from './handlers/issue-handlers.js';
|
|
14
|
+
import { handleMediaRequest } from './handlers/media-handler.js';
|
|
14
15
|
import { handlePlanRequest } from './handlers/plan-handler.js';
|
|
15
16
|
import { handleProjectRequest } from './handlers/project-handlers.js';
|
|
16
17
|
import { createQueueHandler } from './handlers/queue-handler.js';
|
|
17
18
|
import { setupResourceHandlers } from './handlers/resource-handlers.js';
|
|
18
19
|
import { handleSprintRequest } from './handlers/sprint-handlers.js';
|
|
20
|
+
import { handleWorkspaceRequest } from './handlers/workspace-handler.js';
|
|
19
21
|
import { promptDefinitions } from './prompts/prompt-definitions.js';
|
|
20
22
|
import { getPrompt } from './prompts/prompt-messages.js';
|
|
21
23
|
import { toolSchemas } from './schemas/tool-schemas.js';
|
|
24
|
+
import { classifyFieldErrors } from './utils/field-error-classification.js';
|
|
25
|
+
import { normalizeArgs } from './utils/normalize-args.js';
|
|
22
26
|
// Jira credentials from environment variables
|
|
23
27
|
const JIRA_EMAIL = process.env.JIRA_EMAIL;
|
|
24
28
|
const JIRA_API_TOKEN = process.env.JIRA_API_TOKEN;
|
|
@@ -160,6 +164,8 @@ class JiraServer {
|
|
|
160
164
|
manage_jira_sprint: handleSprintRequest,
|
|
161
165
|
manage_jira_filter: handleFilterRequest,
|
|
162
166
|
analyze_jira_issues: (client, req) => handleAnalysisRequest(client, req, this.graphqlClient, this.cache),
|
|
167
|
+
manage_jira_media: (client, req) => handleMediaRequest(client, normalizeArgs(req.params.arguments ?? {})),
|
|
168
|
+
manage_workspace: (_client, req) => handleWorkspaceRequest(normalizeArgs(req.params.arguments ?? {})),
|
|
163
169
|
};
|
|
164
170
|
const handlers = {
|
|
165
171
|
...toolHandlers,
|
|
@@ -243,6 +249,11 @@ class JiraServer {
|
|
|
243
249
|
for (const [field, msg] of Object.entries(fieldErrors)) {
|
|
244
250
|
lines.push(`- \`${field}\`: ${msg}`);
|
|
245
251
|
}
|
|
252
|
+
// Distinguish the failure modes Jira's message conflates (ADR-213 §A5, #49 / #52c).
|
|
253
|
+
const guidance = classifyFieldErrors(fieldErrors);
|
|
254
|
+
if (guidance.length > 0) {
|
|
255
|
+
lines.push('', '**What to do:**', ...guidance);
|
|
256
|
+
}
|
|
246
257
|
}
|
|
247
258
|
// On create failures, invalidate cache and append required fields guidance
|
|
248
259
|
const reqArgs = request.params.arguments;
|
|
@@ -89,13 +89,10 @@ function stripHtml(html) {
|
|
|
89
89
|
.replace(/\s+/g, ' ')
|
|
90
90
|
.trim();
|
|
91
91
|
}
|
|
92
|
-
// ============================================================================
|
|
93
|
-
// Issue Rendering
|
|
94
|
-
// ============================================================================
|
|
95
92
|
/**
|
|
96
93
|
* Render a single issue as markdown
|
|
97
94
|
*/
|
|
98
|
-
export function renderIssue(issue, transitions) {
|
|
95
|
+
export function renderIssue(issue, transitions, opts = {}) {
|
|
99
96
|
const lines = [];
|
|
100
97
|
lines.push(`# ${issue.key}: ${issue.summary}`);
|
|
101
98
|
lines.push('');
|
|
@@ -170,17 +167,33 @@ export function renderIssue(issue, transitions) {
|
|
|
170
167
|
lines.push(`[${startIdx + i}/${issue.comments.length}] ${comment.author} (${formatDate(comment.created)}): ${truncate(preview, 200)}`);
|
|
171
168
|
}
|
|
172
169
|
}
|
|
173
|
-
// Custom fields (
|
|
174
|
-
|
|
170
|
+
// Custom fields — progressive reveal (ADR-214). Default is a breadcrumb pointing at the
|
|
171
|
+
// opt-in expand and the scoped resource; the full dump is gated behind `customFields: 'dump'`.
|
|
172
|
+
// Zero populated → silent in every mode.
|
|
173
|
+
const customFieldsMode = opts.customFields ?? 'breadcrumb';
|
|
174
|
+
const cfs = issue.customFieldValues ?? [];
|
|
175
|
+
const populatedCount = cfs.length;
|
|
176
|
+
if (customFieldsMode === 'dump' && populatedCount > 0) {
|
|
175
177
|
lines.push('');
|
|
176
178
|
lines.push('Custom Fields:');
|
|
177
|
-
for (const cf of
|
|
179
|
+
for (const cf of cfs) {
|
|
178
180
|
const displayValue = Array.isArray(cf.value)
|
|
179
181
|
? cf.value.join(', ')
|
|
180
182
|
: String(cf.value);
|
|
181
183
|
lines.push(`${cf.name} (${cf.type}): ${displayValue}`);
|
|
182
184
|
}
|
|
183
185
|
}
|
|
186
|
+
else if (customFieldsMode === 'breadcrumb' && populatedCount > 0) {
|
|
187
|
+
lines.push('');
|
|
188
|
+
// Issue-type names can contain spaces ("User Story", "Service Request") — the catalog
|
|
189
|
+
// resource emitter encodes the type and the resolver decodes it (resource-handlers.ts:148, 533),
|
|
190
|
+
// so the breadcrumb URI has to encode too or the link round-trips wrong.
|
|
191
|
+
const uri = opts.projectKey && opts.issueTypeName
|
|
192
|
+
? ` For what's settable on this issue type: read \`jira://custom-fields/${opts.projectKey}/${encodeURIComponent(opts.issueTypeName)}\`.`
|
|
193
|
+
: '';
|
|
194
|
+
lines.push(`📋 ${populatedCount} populated custom field${populatedCount === 1 ? '' : 's'} not shown. ` +
|
|
195
|
+
`To view: \`expand: ["custom_fields"]\`.${uri}`);
|
|
196
|
+
}
|
|
184
197
|
// Status history (if requested via expand: ["history"])
|
|
185
198
|
if (issue.statusHistory && issue.statusHistory.length > 0) {
|
|
186
199
|
lines.push('');
|
|
@@ -142,7 +142,7 @@ export const toolSchemas = {
|
|
|
142
142
|
},
|
|
143
143
|
manage_jira_issue: {
|
|
144
144
|
name: 'manage_jira_issue',
|
|
145
|
-
description: 'Get, create, update, delete, move, transition, comment on, link, log work on, or explore hierarchy of Jira issues',
|
|
145
|
+
description: 'Get, create, update, delete, move, transition, comment on, link, log work on, or explore hierarchy of Jira issues. For custom-field writes, consult `jira://capabilities` (field routing) and `jira://custom-fields/{projectKey}/{issueType}` (what is settable here) — some fields need dedicated tools and others auto-resolve names to ids.',
|
|
146
146
|
inputSchema: {
|
|
147
147
|
type: 'object',
|
|
148
148
|
properties: {
|
|
@@ -186,7 +186,7 @@ export const toolSchemas = {
|
|
|
186
186
|
},
|
|
187
187
|
customFields: {
|
|
188
188
|
type: 'object',
|
|
189
|
-
description: 'Custom field values as
|
|
189
|
+
description: 'Custom field values as { name | id : value }. Names auto-resolve to customfield_NNNNN. Read `jira://capabilities` for the field-routing table (which fields need a dedicated tool or have value-resolution) and `jira://custom-fields/{projectKey}/{issueType}` for what is settable on a given screen, with types and clearing semantics.',
|
|
190
190
|
},
|
|
191
191
|
dueDate: {
|
|
192
192
|
type: ['string', 'null'],
|
|
@@ -265,9 +265,9 @@ export const toolSchemas = {
|
|
|
265
265
|
type: 'array',
|
|
266
266
|
items: {
|
|
267
267
|
type: 'string',
|
|
268
|
-
enum: ['comments', 'transitions', 'attachments', 'related_issues', 'history'],
|
|
268
|
+
enum: ['comments', 'transitions', 'attachments', 'related_issues', 'history', 'custom_fields'],
|
|
269
269
|
},
|
|
270
|
-
description: 'Additional fields to include in the response.',
|
|
270
|
+
description: 'Additional fields to include in the response. Populated custom fields are NOT shown by default — `get` emits a one-line breadcrumb with the count and the opt-in. Pass "custom_fields" to render the full populated dump. For what is *settable* on this issue type (with usage hints), read the scoped resource `jira://custom-fields/{projectKey}/{issueType}` instead.',
|
|
271
271
|
},
|
|
272
272
|
},
|
|
273
273
|
required: ['operation'],
|
|
@@ -490,6 +490,72 @@ export const toolSchemas = {
|
|
|
490
490
|
required: [],
|
|
491
491
|
},
|
|
492
492
|
},
|
|
493
|
+
manage_jira_media: {
|
|
494
|
+
name: 'manage_jira_media',
|
|
495
|
+
description: 'Manage file attachments on Jira issues (remote). Operations here affect Jira — delete permanently removes an attachment from the issue for all users. Use manage_workspace for local file staging. Downloads copy from Jira to workspace; uploads copy from workspace to Jira.',
|
|
496
|
+
inputSchema: {
|
|
497
|
+
type: 'object',
|
|
498
|
+
properties: {
|
|
499
|
+
operation: {
|
|
500
|
+
type: 'string',
|
|
501
|
+
enum: ['list', 'upload', 'download', 'view', 'get_info', 'delete'],
|
|
502
|
+
description: 'Operation to perform. list: attachments on an issue. upload: copy file from workspace to Jira issue. download: copy attachment from Jira to local workspace. view: display image inline. get_info: attachment metadata. delete: permanently remove attachment from Jira (affects all users).',
|
|
503
|
+
},
|
|
504
|
+
issueKey: {
|
|
505
|
+
type: 'string',
|
|
506
|
+
description: 'Issue key (e.g., PROJ-123). Required for list and upload.',
|
|
507
|
+
},
|
|
508
|
+
attachmentId: {
|
|
509
|
+
type: 'string',
|
|
510
|
+
description: 'Attachment ID. Required for download, view, get_info, delete.',
|
|
511
|
+
},
|
|
512
|
+
filename: {
|
|
513
|
+
type: 'string',
|
|
514
|
+
description: 'Filename for upload (required) or download (optional override).',
|
|
515
|
+
},
|
|
516
|
+
content: {
|
|
517
|
+
type: 'string',
|
|
518
|
+
description: 'Base64-encoded file content for upload. Alternative to workspaceFile.',
|
|
519
|
+
},
|
|
520
|
+
mediaType: {
|
|
521
|
+
type: 'string',
|
|
522
|
+
description: 'MIME type (e.g., "image/png", "application/pdf"). Required for upload.',
|
|
523
|
+
},
|
|
524
|
+
workspaceFile: {
|
|
525
|
+
type: 'string',
|
|
526
|
+
description: 'Filename in workspace to upload. Alternative to content. Use manage_workspace list to see staged files.',
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
required: ['operation'],
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
manage_workspace: {
|
|
533
|
+
name: 'manage_workspace',
|
|
534
|
+
description: 'Manage files in the local workspace staging area (local only — no Jira impact). Files downloaded via manage_jira_media land here. Delete only removes the local copy. Use manage_jira_media to affect attachments on Jira issues.',
|
|
535
|
+
inputSchema: {
|
|
536
|
+
type: 'object',
|
|
537
|
+
properties: {
|
|
538
|
+
operation: {
|
|
539
|
+
type: 'string',
|
|
540
|
+
enum: ['list', 'read', 'write', 'delete', 'mkdir', 'move'],
|
|
541
|
+
description: 'Operation to perform. list: show staged files. read: display file content. write: stage base64 content. delete: remove local file only (does not affect Jira). mkdir: create directory. move: rename/relocate file.',
|
|
542
|
+
},
|
|
543
|
+
filename: {
|
|
544
|
+
type: 'string',
|
|
545
|
+
description: 'Filename or path within workspace. Supports nesting with / separators.',
|
|
546
|
+
},
|
|
547
|
+
destination: {
|
|
548
|
+
type: 'string',
|
|
549
|
+
description: 'Destination path for move operation.',
|
|
550
|
+
},
|
|
551
|
+
content: {
|
|
552
|
+
type: 'string',
|
|
553
|
+
description: 'Base64-encoded content for write operation.',
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
required: ['operation'],
|
|
557
|
+
},
|
|
558
|
+
},
|
|
493
559
|
queue_jira_operations: {
|
|
494
560
|
name: 'queue_jira_operations',
|
|
495
561
|
description: 'Execute multiple Jira operations in a single call. Operations run sequentially with result references ($0.key) and per-operation error strategies (bail/continue). Powerful for analysis pipelines: create a filter, then run multiple analyze_jira_issues calls against $0.filterId with different groupBy/compute — all in one call.',
|
|
@@ -503,7 +569,7 @@ export const toolSchemas = {
|
|
|
503
569
|
properties: {
|
|
504
570
|
tool: {
|
|
505
571
|
type: 'string',
|
|
506
|
-
enum: ['manage_jira_issue', 'manage_jira_filter', 'manage_jira_sprint', 'manage_jira_project', 'manage_jira_board', 'analyze_jira_issues'],
|
|
572
|
+
enum: ['manage_jira_issue', 'manage_jira_filter', 'manage_jira_sprint', 'manage_jira_project', 'manage_jira_board', 'analyze_jira_issues', 'manage_jira_media', 'manage_workspace'],
|
|
507
573
|
description: 'Which tool to call.',
|
|
508
574
|
},
|
|
509
575
|
args: {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field-rejection error classification (ADR-213 §A5, issues #49 and #52c).
|
|
3
|
+
*
|
|
4
|
+
* Jira rejects a `customFields` write with one opaque message —
|
|
5
|
+
* "Field 'X' cannot be set. It is not on the appropriate screen, or unknown."
|
|
6
|
+
* — that conflates several failure modes, each needing a different recovery. Given Jira's
|
|
7
|
+
* field-error map (keyed by field id or name), this returns extra guidance lines that pull
|
|
8
|
+
* those modes apart:
|
|
9
|
+
*
|
|
10
|
+
* - field has a dedicated path (Sprint, Epic Link, Parent, Rank) → point at the right tool/param
|
|
11
|
+
* - field exists in the catalog but isn't writable here → "editable inline in the UI, or via the
|
|
12
|
+
* owning app — not reachable through the standard edit endpoint"
|
|
13
|
+
* - field isn't in the catalog → "not a field this instance exposes — see jira://custom-fields"
|
|
14
|
+
* - catalog unavailable → say so; the name may be right but can't be verified here
|
|
15
|
+
*/
|
|
16
|
+
import { fieldDiscovery } from '../client/field-discovery.js';
|
|
17
|
+
import { routeForField } from '../extensions/index.js';
|
|
18
|
+
/** Looks like a Connect/Forge app field key (e.g. `io.tempo.jira__account`) rather than a
|
|
19
|
+
* `customfield_NNNNN` id or a plain field name — Jira sometimes reports field errors this way. */
|
|
20
|
+
function looksLikeAppFieldKey(key) {
|
|
21
|
+
return !key.startsWith('customfield_') && (key.includes('__') || /^[a-z][\w-]*(\.[\w-]+){2,}/i.test(key));
|
|
22
|
+
}
|
|
23
|
+
export function classifyFieldErrors(fieldErrors) {
|
|
24
|
+
const out = [];
|
|
25
|
+
const catalogState = fieldDiscovery.getState();
|
|
26
|
+
for (const fieldKey of Object.keys(fieldErrors)) {
|
|
27
|
+
const catalogEntry = fieldKey.startsWith('customfield_')
|
|
28
|
+
? fieldDiscovery.getFieldById(fieldKey)
|
|
29
|
+
: undefined;
|
|
30
|
+
const humanName = catalogEntry?.name ?? fieldKey;
|
|
31
|
+
const resolvedId = fieldKey.startsWith('customfield_')
|
|
32
|
+
? fieldKey
|
|
33
|
+
: fieldDiscovery.resolveNameToId(fieldKey);
|
|
34
|
+
const isKnownField = !!catalogEntry || !!resolvedId;
|
|
35
|
+
const route = routeForField(fieldKey) ?? routeForField(humanName);
|
|
36
|
+
if (route) {
|
|
37
|
+
out.push(` → \`${humanName}\`: ${route.unhandled.message}`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (isKnownField) {
|
|
41
|
+
out.push(` → \`${humanName}\` exists on this instance but the write was rejected — it may be off ` +
|
|
42
|
+
`the Edit screen for this issue type, hidden by a field configuration, or an app-managed ` +
|
|
43
|
+
`field that wants a different value format (e.g. a numeric id rather than a name). It may ` +
|
|
44
|
+
`still be editable inline in the Jira UI.`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (looksLikeAppFieldKey(fieldKey)) {
|
|
48
|
+
out.push(` → \`${fieldKey}\` is a field registered by a Connect/Forge app — the write was rejected ` +
|
|
49
|
+
`(see the message above). App-managed fields often expect a specific value format ` +
|
|
50
|
+
`(e.g. a numeric account/option id rather than a name) or must be set through the app's own ` +
|
|
51
|
+
`interface; setting it inline in the Jira UI usually works.`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (catalogState === 'unavailable') {
|
|
55
|
+
out.push(` → \`${fieldKey}\`: couldn't verify this field name — the custom-field catalog is ` +
|
|
56
|
+
`unavailable (the admin field API returned 403 and the basic fallback also failed). The ` +
|
|
57
|
+
`name may be correct but can't be checked here.`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
out.push(` → \`${fieldKey}\` isn't a custom field this instance exposes (checked the ${catalogState} ` +
|
|
61
|
+
`catalog). Read jira://custom-fields for available names, or ` +
|
|
62
|
+
`jira://custom-fields/{projectKey}/{issueType} for the fields on a specific issue type.`);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
@@ -18,7 +18,7 @@ export function issueNextSteps(operation, issueKey) {
|
|
|
18
18
|
steps.push({ description: 'Transition to a new status', tool: 'manage_jira_issue', example: { operation: 'transition', issueKey, expand: ['transitions'] } }, { description: 'Add to a sprint', tool: 'manage_jira_sprint', example: { operation: 'manage_issues', sprintId: '<id>', add: [issueKey] } }, { description: 'Link to a related issue', tool: 'manage_jira_issue', example: { operation: 'link', issueKey, linkedIssueKey: '<key>', linkType: 'relates to' } }, { description: 'Read jira://custom-fields to discover available custom fields for this instance' });
|
|
19
19
|
break;
|
|
20
20
|
case 'get':
|
|
21
|
-
steps.push({ description: 'Update fields', tool: 'manage_jira_issue', example: { operation: 'update', issueKey } }, { description: 'Add a comment', tool: 'manage_jira_issue', example: { operation: 'comment', issueKey, comment: '<text>' } }, { description: 'View available transitions', tool: 'manage_jira_issue', example: { operation: 'get', issueKey, expand: ['transitions'] } });
|
|
21
|
+
steps.push({ description: 'Update fields', tool: 'manage_jira_issue', example: { operation: 'update', issueKey } }, { description: 'Add a comment', tool: 'manage_jira_issue', example: { operation: 'comment', issueKey, comment: '<text>' } }, { description: 'View available transitions', tool: 'manage_jira_issue', example: { operation: 'get', issueKey, expand: ['transitions'] } }, { description: 'Manage attachments (upload, download, view)', tool: 'manage_jira_media', example: { operation: 'list', issueKey } });
|
|
22
22
|
break;
|
|
23
23
|
case 'update':
|
|
24
24
|
steps.push({ description: 'View the updated issue', tool: 'manage_jira_issue', example: { operation: 'get', issueKey } }, { description: 'Transition to a new status', tool: 'manage_jira_issue', example: { operation: 'get', issueKey, expand: ['transitions'] } }, { description: 'Read jira://custom-fields to discover available custom fields for this instance' });
|
|
@@ -207,6 +207,21 @@ export function goalNextSteps(operation, goalKey, workItemCount) {
|
|
|
207
207
|
}
|
|
208
208
|
return steps.length > 0 ? formatSteps(steps) : '';
|
|
209
209
|
}
|
|
210
|
+
export function mediaNextSteps(operation, context) {
|
|
211
|
+
const steps = [];
|
|
212
|
+
switch (operation) {
|
|
213
|
+
case 'list':
|
|
214
|
+
steps.push({ description: 'Download an attachment to workspace', tool: 'manage_jira_media', example: { operation: 'download', attachmentId: '<id>' } }, { description: 'View an image attachment inline', tool: 'manage_jira_media', example: { operation: 'view', attachmentId: '<id>' } }, { description: 'Upload a file to this issue', tool: 'manage_jira_media', example: { operation: 'upload', issueKey: context.issueKey, filename: '<name>', mediaType: '<mime>', workspaceFile: '<staged file>' } });
|
|
215
|
+
break;
|
|
216
|
+
case 'upload':
|
|
217
|
+
steps.push({ description: 'List attachments on this issue', tool: 'manage_jira_media', example: { operation: 'list', issueKey: context.issueKey } }, { description: 'View the issue', tool: 'manage_jira_issue', example: { operation: 'get', issueKey: context.issueKey } });
|
|
218
|
+
break;
|
|
219
|
+
case 'download':
|
|
220
|
+
steps.push({ description: 'View staged files in workspace', tool: 'manage_workspace', example: { operation: 'list' } }, { description: 'Upload to another issue', tool: 'manage_jira_media', example: { operation: 'upload', issueKey: '<target issue>', workspaceFile: '<filename>', mediaType: '<mime>', filename: '<filename>' } }, { description: 'Read the downloaded file', tool: 'manage_workspace', example: { operation: 'read', filename: '<filename>' } });
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
return steps.length > 0 ? formatSteps(steps) : '';
|
|
224
|
+
}
|
|
210
225
|
export function analysisNextSteps(jql, issueKeys, truncated = false, groupBy, filterSource) {
|
|
211
226
|
const steps = [];
|
|
212
227
|
if (issueKeys.length > 0) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { checkWorkspaceStatus, dataDir, ensureParentDir, ensureWorkspaceDir, formatSize, getWorkspaceDir, resolveWorkspacePath, sanitizeFilename, sanitizePath, validateWorkspaceDir, verifyPathSafety, } from './workspace.js';
|