@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,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