@aaronsb/jira-cloud-mcp 0.9.0 → 0.10.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/jira-client.js +41 -0
- package/build/handlers/media-handler.js +130 -0
- package/build/handlers/workspace-handler.js +214 -0
- package/build/index.js +5 -0
- package/build/schemas/tool-schemas.js +67 -1
- 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
|
@@ -1131,6 +1131,47 @@ export class JiraClient {
|
|
|
1131
1131
|
jql: result.jql || '',
|
|
1132
1132
|
};
|
|
1133
1133
|
}
|
|
1134
|
+
// ── Attachment Operations ────────────────────────────────
|
|
1135
|
+
async getAttachmentInfo(attachmentId) {
|
|
1136
|
+
const meta = await this.client.issueAttachments.getAttachment(attachmentId);
|
|
1137
|
+
return {
|
|
1138
|
+
id: meta.id?.toString() ?? attachmentId,
|
|
1139
|
+
filename: meta.filename ?? 'unnamed',
|
|
1140
|
+
mimeType: meta.mimeType ?? 'application/octet-stream',
|
|
1141
|
+
size: meta.size ?? 0,
|
|
1142
|
+
created: meta.created ?? '',
|
|
1143
|
+
author: meta.author?.displayName ?? 'Unknown',
|
|
1144
|
+
url: meta.content ?? '',
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
async downloadAttachment(attachmentId) {
|
|
1148
|
+
const content = await this.client.issueAttachments.getAttachmentContent(attachmentId);
|
|
1149
|
+
// jira.js generic defaults to Buffer; ensure we return Buffer even if runtime type differs
|
|
1150
|
+
return Buffer.isBuffer(content) ? content : Buffer.from(content);
|
|
1151
|
+
}
|
|
1152
|
+
async uploadAttachment(issueKey, filename, content, mimeType) {
|
|
1153
|
+
const result = await this.client.issueAttachments.addAttachment({
|
|
1154
|
+
issueIdOrKey: issueKey,
|
|
1155
|
+
attachment: {
|
|
1156
|
+
filename,
|
|
1157
|
+
file: content,
|
|
1158
|
+
mimeType,
|
|
1159
|
+
},
|
|
1160
|
+
});
|
|
1161
|
+
const att = Array.isArray(result) ? result[0] : result;
|
|
1162
|
+
return {
|
|
1163
|
+
id: att.id?.toString() ?? '',
|
|
1164
|
+
filename: att.filename ?? filename,
|
|
1165
|
+
mimeType: att.mimeType ?? mimeType,
|
|
1166
|
+
size: att.size ?? content.length,
|
|
1167
|
+
created: att.created ?? new Date().toISOString(),
|
|
1168
|
+
author: att.author?.displayName ?? 'Unknown',
|
|
1169
|
+
url: att.content ?? '',
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
async deleteAttachment(attachmentId) {
|
|
1173
|
+
await this.client.issueAttachments.removeAttachment(attachmentId);
|
|
1174
|
+
}
|
|
1134
1175
|
async deleteFilter(filterId) {
|
|
1135
1176
|
await this.client.filters.deleteFilter(filterId);
|
|
1136
1177
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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,17 @@ 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 { normalizeArgs } from './utils/normalize-args.js';
|
|
22
25
|
// Jira credentials from environment variables
|
|
23
26
|
const JIRA_EMAIL = process.env.JIRA_EMAIL;
|
|
24
27
|
const JIRA_API_TOKEN = process.env.JIRA_API_TOKEN;
|
|
@@ -160,6 +163,8 @@ class JiraServer {
|
|
|
160
163
|
manage_jira_sprint: handleSprintRequest,
|
|
161
164
|
manage_jira_filter: handleFilterRequest,
|
|
162
165
|
analyze_jira_issues: (client, req) => handleAnalysisRequest(client, req, this.graphqlClient, this.cache),
|
|
166
|
+
manage_jira_media: (client, req) => handleMediaRequest(client, normalizeArgs(req.params.arguments ?? {})),
|
|
167
|
+
manage_workspace: (_client, req) => handleWorkspaceRequest(normalizeArgs(req.params.arguments ?? {})),
|
|
163
168
|
};
|
|
164
169
|
const handlers = {
|
|
165
170
|
...toolHandlers,
|
|
@@ -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: {
|
|
@@ -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';
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace directory — safe sandbox for file staging operations.
|
|
3
|
+
*
|
|
4
|
+
* All file operations (attachment download, upload, media staging) are jailed
|
|
5
|
+
* to this directory. Prevents agents from accidentally operating on home
|
|
6
|
+
* directories, document folders, or cloud sync mount points.
|
|
7
|
+
*
|
|
8
|
+
* See ADR-211: Attachment and Workspace Management.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'node:fs/promises';
|
|
11
|
+
import * as os from 'node:os';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
const APP_NAME = 'jira-cloud-mcp';
|
|
14
|
+
// ── XDG Paths ──────────────────────────────────────────────
|
|
15
|
+
export function dataDir() {
|
|
16
|
+
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
|
17
|
+
return path.join(base, APP_NAME);
|
|
18
|
+
}
|
|
19
|
+
// ── Forbidden Paths ────────────────────────────────────────
|
|
20
|
+
/** Paths that must never be used as the workspace root. */
|
|
21
|
+
const FORBIDDEN_PATHS = [
|
|
22
|
+
() => process.env.HOME ?? '',
|
|
23
|
+
() => process.env.USERPROFILE ?? '',
|
|
24
|
+
() => process.env.HOME ? path.join(process.env.HOME, 'Documents') : '',
|
|
25
|
+
() => process.env.HOME ? path.join(process.env.HOME, 'Desktop') : '',
|
|
26
|
+
() => process.env.HOME ? path.join(process.env.HOME, 'Downloads') : '',
|
|
27
|
+
() => process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'Documents') : '',
|
|
28
|
+
() => process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'Desktop') : '',
|
|
29
|
+
() => process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'Downloads') : '',
|
|
30
|
+
];
|
|
31
|
+
/** Path substrings that indicate a cloud sync mount. */
|
|
32
|
+
const CLOUD_SYNC_PATTERNS = [
|
|
33
|
+
'google-drive',
|
|
34
|
+
'Google Drive',
|
|
35
|
+
'GoogleDrive',
|
|
36
|
+
'gdrive',
|
|
37
|
+
'My Drive',
|
|
38
|
+
'OneDrive',
|
|
39
|
+
'onedrive',
|
|
40
|
+
'Dropbox',
|
|
41
|
+
'dropbox',
|
|
42
|
+
'iCloud Drive',
|
|
43
|
+
'iCloudDrive',
|
|
44
|
+
];
|
|
45
|
+
// ── Workspace Directory ────────────────────────────────────
|
|
46
|
+
/** Get the workspace directory path, respecting env overrides. */
|
|
47
|
+
export function getWorkspaceDir() {
|
|
48
|
+
const configured = process.env.WORKSPACE_DIR;
|
|
49
|
+
if (configured && !configured.includes('${')) {
|
|
50
|
+
return configured;
|
|
51
|
+
}
|
|
52
|
+
return path.join(dataDir(), 'workspace');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Validate workspace dir is safe. Throws if it IS a protected directory.
|
|
56
|
+
* Being a subdirectory OF a protected directory is fine.
|
|
57
|
+
*/
|
|
58
|
+
export function validateWorkspaceDir(dir) {
|
|
59
|
+
const resolved = path.resolve(dir);
|
|
60
|
+
for (const getForbidden of FORBIDDEN_PATHS) {
|
|
61
|
+
const forbidden = getForbidden();
|
|
62
|
+
if (forbidden && path.resolve(forbidden) === resolved) {
|
|
63
|
+
throw new Error(`Workspace directory cannot be ${resolved} — use a subdirectory like ${getWorkspaceDir()}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const pattern of CLOUD_SYNC_PATTERNS) {
|
|
67
|
+
if (resolved.toLowerCase().includes(pattern.toLowerCase())) {
|
|
68
|
+
throw new Error(`Workspace directory cannot be inside a cloud sync mount (${resolved}) — this could cause sync conflicts`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (resolved === '/' || resolved === 'C:\\') {
|
|
72
|
+
throw new Error('Workspace directory cannot be the filesystem root');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Check workspace directory status without throwing. */
|
|
76
|
+
export function checkWorkspaceStatus() {
|
|
77
|
+
const dir = getWorkspaceDir();
|
|
78
|
+
try {
|
|
79
|
+
validateWorkspaceDir(dir);
|
|
80
|
+
return { path: dir, valid: true };
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
return { path: dir, valid: false, warning: err.message };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Ensure the workspace directory exists and is validated. */
|
|
87
|
+
export async function ensureWorkspaceDir() {
|
|
88
|
+
const status = checkWorkspaceStatus();
|
|
89
|
+
if (status.valid) {
|
|
90
|
+
await fs.mkdir(status.path, { recursive: true, mode: 0o755 });
|
|
91
|
+
}
|
|
92
|
+
return status;
|
|
93
|
+
}
|
|
94
|
+
// ── Filename Sanitization ──────────────────────────────────
|
|
95
|
+
/**
|
|
96
|
+
* Sanitize a single filename segment (no separators).
|
|
97
|
+
* Strips null bytes, control characters, path separators, and dangerous chars.
|
|
98
|
+
*/
|
|
99
|
+
export function sanitizeFilename(filename) {
|
|
100
|
+
return filename
|
|
101
|
+
// Remove null bytes and control characters
|
|
102
|
+
// eslint-disable-next-line no-control-regex
|
|
103
|
+
.replace(/[\x00-\x1f\x7f]/g, '')
|
|
104
|
+
// Remove path separators
|
|
105
|
+
.replace(/[/\\]/g, '_')
|
|
106
|
+
// Remove other dangerous characters
|
|
107
|
+
.replace(/[<>:"|?*]/g, '_')
|
|
108
|
+
// Collapse multiple underscores
|
|
109
|
+
.replace(/_+/g, '_')
|
|
110
|
+
// Remove leading dots (hidden files) and trailing dots/spaces
|
|
111
|
+
.replace(/^\.+/, '')
|
|
112
|
+
.replace(/[. ]+$/, '')
|
|
113
|
+
|| 'unnamed';
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Sanitize a workspace path that may contain directory separators.
|
|
117
|
+
* Each path segment is sanitized individually, preserving the directory structure.
|
|
118
|
+
* Empty segments and traversal attempts (e.g. '..') are removed.
|
|
119
|
+
*/
|
|
120
|
+
export function sanitizePath(filePath) {
|
|
121
|
+
// Normalize separators to forward slash, split into segments
|
|
122
|
+
const segments = filePath
|
|
123
|
+
.replace(/\\/g, '/')
|
|
124
|
+
.split('/')
|
|
125
|
+
.map(seg => sanitizeFilename(seg))
|
|
126
|
+
// Drop segments that sanitized to 'unnamed' (e.g. '..' → '' → 'unnamed').
|
|
127
|
+
// Only keep 'unnamed' if the entire input was empty (fallback sentinel).
|
|
128
|
+
.filter(seg => seg !== 'unnamed' || filePath.trim() === '');
|
|
129
|
+
if (segments.length === 0)
|
|
130
|
+
return 'unnamed';
|
|
131
|
+
return segments.join(path.sep);
|
|
132
|
+
}
|
|
133
|
+
// ── Path Resolution ────────────────────────────────────────
|
|
134
|
+
/**
|
|
135
|
+
* Resolve a file path within the workspace directory.
|
|
136
|
+
* Supports nested paths (e.g. 'projects/report.csv').
|
|
137
|
+
* Prevents path traversal and sanitizes each path segment.
|
|
138
|
+
*/
|
|
139
|
+
export function resolveWorkspacePath(filePath) {
|
|
140
|
+
const dir = getWorkspaceDir();
|
|
141
|
+
// Use sanitizePath for nested paths, sanitizeFilename for flat filenames
|
|
142
|
+
const sanitized = filePath.includes('/') || filePath.includes('\\')
|
|
143
|
+
? sanitizePath(filePath)
|
|
144
|
+
: sanitizeFilename(filePath);
|
|
145
|
+
const resolved = path.resolve(dir, sanitized);
|
|
146
|
+
const resolvedDir = path.resolve(dir);
|
|
147
|
+
if (!resolved.startsWith(resolvedDir + path.sep) && resolved !== resolvedDir) {
|
|
148
|
+
throw new Error(`Path traversal detected: "${filePath}" resolves outside workspace directory`);
|
|
149
|
+
}
|
|
150
|
+
return resolved;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Ensure parent directories exist for a workspace path.
|
|
154
|
+
* Call after resolveWorkspacePath to create intermediate dirs.
|
|
155
|
+
*/
|
|
156
|
+
export async function ensureParentDir(filePath) {
|
|
157
|
+
const dir = path.dirname(filePath);
|
|
158
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o755 });
|
|
159
|
+
}
|
|
160
|
+
// ── Formatting ─────────────────────────────────────────────
|
|
161
|
+
/** Format byte count as human-readable size. */
|
|
162
|
+
export function formatSize(bytes) {
|
|
163
|
+
if (bytes < 1024)
|
|
164
|
+
return `${bytes}B`;
|
|
165
|
+
if (bytes < 1024 * 1024)
|
|
166
|
+
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
167
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
168
|
+
}
|
|
169
|
+
// ── Path Safety ────────────────────────────────────────────
|
|
170
|
+
/**
|
|
171
|
+
* Verify a file path is safe after symlink resolution.
|
|
172
|
+
* Must be called before any fs operation on a workspace path.
|
|
173
|
+
*/
|
|
174
|
+
export async function verifyPathSafety(filePath) {
|
|
175
|
+
const dir = path.resolve(getWorkspaceDir());
|
|
176
|
+
try {
|
|
177
|
+
const real = await fs.realpath(filePath);
|
|
178
|
+
if (!real.startsWith(dir + path.sep) && real !== dir) {
|
|
179
|
+
throw new Error(`Symlink escape detected: "${filePath}" resolves to "${real}" outside workspace`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
if (err.code === 'ENOENT')
|
|
184
|
+
return;
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
}
|
package/package.json
CHANGED