@dpesch/mantisbt-mcp-server 1.10.2 → 1.10.6

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.de.md +2 -1
  3. package/README.md +2 -1
  4. package/dist/client.js +0 -15
  5. package/dist/tools/files.js +14 -15
  6. package/docs/cookbook.de.md +1 -1
  7. package/docs/cookbook.md +1 -1
  8. package/package.json +1 -1
  9. package/server.json +2 -2
  10. package/.gitea/PULL_REQUEST_TEMPLATE.md +0 -17
  11. package/.gitea/workflows/ci.yml +0 -95
  12. package/.github/workflows/ci.yml +0 -32
  13. package/scripts/hooks/pre-push.mjs +0 -220
  14. package/scripts/init.mjs +0 -95
  15. package/scripts/record-fixtures.ts +0 -160
  16. package/tests/cache.test.ts +0 -265
  17. package/tests/client.test.ts +0 -372
  18. package/tests/config.test.ts +0 -263
  19. package/tests/fixtures/get_current_user.json +0 -40
  20. package/tests/fixtures/get_issue.json +0 -133
  21. package/tests/fixtures/get_issue_enums.json +0 -67
  22. package/tests/fixtures/get_issue_fields_sample.json +0 -76
  23. package/tests/fixtures/get_project_categories.json +0 -157
  24. package/tests/fixtures/get_project_versions.json +0 -3
  25. package/tests/fixtures/get_project_versions_with_data.json +0 -52
  26. package/tests/fixtures/list_issues.json +0 -95
  27. package/tests/fixtures/list_projects.json +0 -65
  28. package/tests/helpers/mock-server.ts +0 -166
  29. package/tests/helpers/search-mocks.ts +0 -84
  30. package/tests/prompts/prompts.test.ts +0 -242
  31. package/tests/resources/resources.test.ts +0 -309
  32. package/tests/search/embedder.test.ts +0 -81
  33. package/tests/search/highlight.test.ts +0 -129
  34. package/tests/search/store.test.ts +0 -193
  35. package/tests/search/sync.test.ts +0 -249
  36. package/tests/search/tools.test.ts +0 -661
  37. package/tests/tools/config.test.ts +0 -212
  38. package/tests/tools/files.test.ts +0 -344
  39. package/tests/tools/issues.test.ts +0 -1180
  40. package/tests/tools/metadata.test.ts +0 -509
  41. package/tests/tools/monitors.test.ts +0 -101
  42. package/tests/tools/projects.test.ts +0 -338
  43. package/tests/tools/relationships.test.ts +0 -177
  44. package/tests/tools/string-coercion.test.ts +0 -317
  45. package/tests/tools/users.test.ts +0 -62
  46. package/tests/utils/date-filter.test.ts +0 -169
  47. package/tests/version-hint.test.ts +0 -230
  48. package/tsconfig.build.json +0 -8
  49. package/vitest.config.ts +0 -8
@@ -1,212 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
-
3
- type EnumEntry = { id: number; name: string; label?: string; canonical_name?: string };
4
- type EnumResult = Record<string, Array<EnumEntry>>;
5
- import { readFileSync } from 'node:fs';
6
- import { join, dirname } from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
- import { MantisClient } from '../../src/client.js';
9
- import { MetadataCache } from '../../src/cache.js';
10
- import { registerConfigTools } from '../../src/tools/config.js';
11
- import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
12
-
13
- const __filename = fileURLToPath(import.meta.url);
14
- const __dirname = dirname(__filename);
15
- const fixturesDir = join(__dirname, '..', 'fixtures');
16
-
17
- // ---------------------------------------------------------------------------
18
- // Fixtures
19
- // ---------------------------------------------------------------------------
20
-
21
- const enumFixture = JSON.parse(
22
- readFileSync(join(fixturesDir, 'get_issue_enums.json'), 'utf-8')
23
- ) as { configs: Array<{ option: string; value: Array<{ id: number; name: string; label: string }> }> };
24
-
25
- // Legacy: some MantisBT versions return value as a comma-separated "id:name" string
26
- const ENUM_FIXTURE_STRING = {
27
- configs: [
28
- { option: 'severity_enum_string', value: '10:feature,50:minor,80:block' },
29
- { option: 'status_enum_string', value: '10:new,80:resolved,90:closed' },
30
- { option: 'priority_enum_string', value: '10:none,30:normal,60:immediate' },
31
- { option: 'resolution_enum_string', value: '10:open,20:fixed' },
32
- { option: 'reproducibility_enum_string', value: '10:always,70:have not tried' },
33
- ],
34
- };
35
-
36
- // ---------------------------------------------------------------------------
37
- // Setup
38
- // ---------------------------------------------------------------------------
39
-
40
- let mockServer: MockMcpServer;
41
- let client: MantisClient;
42
- let cache: MetadataCache;
43
-
44
- beforeEach(() => {
45
- mockServer = new MockMcpServer();
46
- client = new MantisClient('https://mantis.example.com', 'test-token');
47
- cache = new MetadataCache('/tmp/cache-test', 86400);
48
- registerConfigTools(mockServer as never, client, cache);
49
- vi.stubGlobal('fetch', vi.fn());
50
- });
51
-
52
- afterEach(() => {
53
- vi.unstubAllGlobals();
54
- });
55
-
56
- // ---------------------------------------------------------------------------
57
- // get_issue_enums
58
- // ---------------------------------------------------------------------------
59
-
60
- describe('get_issue_enums', () => {
61
- it('ist registriert', () => {
62
- expect(mockServer.hasToolRegistered('get_issue_enums')).toBe(true);
63
- });
64
-
65
- describe('mit Array-Format (MantisBT 2.x)', () => {
66
- let parsed: EnumResult;
67
-
68
- beforeEach(async () => {
69
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(enumFixture)));
70
- const result = await mockServer.callTool('get_issue_enums', {});
71
- parsed = JSON.parse(result.content[0]!.text) as EnumResult;
72
- });
73
-
74
- it('gibt strukturierte Enum-Arrays für alle 5 Felder zurück', () => {
75
- expect(Array.isArray(parsed.severity)).toBe(true);
76
- expect(Array.isArray(parsed.status)).toBe(true);
77
- expect(Array.isArray(parsed.priority)).toBe(true);
78
- expect(Array.isArray(parsed.resolution)).toBe(true);
79
- expect(Array.isArray(parsed.reproducibility)).toBe(true);
80
- });
81
-
82
- it('id und name korrekt für alle Felder', () => {
83
- expect(parsed.severity).toContainEqual(expect.objectContaining({ id: 50, name: 'kleinerer Fehler' }));
84
- expect(parsed.severity).toContainEqual(expect.objectContaining({ id: 80, name: 'Blocker' }));
85
- expect(parsed.severity).toContainEqual(expect.objectContaining({ id: 200, name: 'Technische Schuld' }));
86
- expect(parsed.status).toContainEqual(expect.objectContaining({ id: 10, name: 'new' }));
87
- expect(parsed.status).toContainEqual(expect.objectContaining({ id: 80, name: 'resolved' }));
88
- expect(parsed.priority).toContainEqual(expect.objectContaining({ id: 30, name: 'normal' }));
89
- expect(parsed.reproducibility).toContainEqual(expect.objectContaining({ id: 70, name: 'have not tried' }));
90
- });
91
-
92
- it('label wird ausgegeben wenn er sich von name unterscheidet (lokalisierte Installation)', () => {
93
- // status: name="new", label="neu" → label muss im Output enthalten sein
94
- expect(parsed.status).toContainEqual({ id: 10, name: 'new', label: 'neu' });
95
- expect(parsed.status).toContainEqual({ id: 80, name: 'resolved', label: 'erledigt' });
96
- // priority: name="none", label="keine" → label muss im Output enthalten sein
97
- expect(parsed.priority).toContainEqual({ id: 10, name: 'none', label: 'keine' });
98
- // reproducibility: name="always", label="immer" → label muss im Output enthalten sein
99
- expect(parsed.reproducibility).toContainEqual({ id: 10, name: 'always', label: 'immer' });
100
- });
101
-
102
- it('label wird weggelassen wenn er identisch mit name ist', () => {
103
- // severity: name === label (z.B. "Feature-Wunsch" === "Feature-Wunsch") → kein label-Feld
104
- // canonical_name is present because "Feature-Wunsch" differs from English "feature"
105
- const severityFirst = parsed.severity.find(e => e.id === 10)!;
106
- expect(severityFirst).toMatchObject({ id: 10, name: 'Feature-Wunsch', canonical_name: 'feature' });
107
- expect(severityFirst).not.toHaveProperty('label');
108
-
109
- // priority: name="normal", label="normal" → kein label-Feld
110
- const priorityNormal = parsed.priority.find(e => e.id === 30)!;
111
- expect(priorityNormal).toEqual({ id: 30, name: 'normal' });
112
- expect(priorityNormal).not.toHaveProperty('label');
113
- });
114
-
115
- it('fragt alle 5 Enum-Optionen ab', () => {
116
- const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
117
- expect(calledUrl).toContain('severity_enum_string');
118
- expect(calledUrl).toContain('status_enum_string');
119
- expect(calledUrl).toContain('priority_enum_string');
120
- expect(calledUrl).toContain('resolution_enum_string');
121
- expect(calledUrl).toContain('reproducibility_enum_string');
122
- });
123
- });
124
-
125
- it('parst String-Format (Legacy): "id:name,..."-Strings korrekt', async () => {
126
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(ENUM_FIXTURE_STRING)));
127
-
128
- const result = await mockServer.callTool('get_issue_enums', {});
129
-
130
- const parsed = JSON.parse(result.content[0]!.text) as EnumResult;
131
-
132
- expect(parsed.severity).toContainEqual({ id: 50, name: 'minor' });
133
- expect(parsed.status).toContainEqual({ id: 80, name: 'resolved' });
134
- expect(parsed.reproducibility).toContainEqual({ id: 70, name: 'have not tried' });
135
- });
136
-
137
- describe('canonical_name für lokalisierte Installationen', () => {
138
- it('fügt canonical_name hinzu wenn name von englischem Standardwert abweicht', async () => {
139
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(enumFixture)));
140
- const result = await mockServer.callTool('get_issue_enums', {});
141
- const parsed = JSON.parse(result.content[0]!.text) as EnumResult;
142
-
143
- // severity: names are German in fixture → canonical_name must be present
144
- const minor = parsed.severity.find(e => e.id === 50)!;
145
- expect(minor.name).toBe('kleinerer Fehler');
146
- expect(minor.canonical_name).toBe('minor');
147
-
148
- const block = parsed.severity.find(e => e.id === 80)!;
149
- expect(block.name).toBe('Blocker');
150
- expect(block.canonical_name).toBe('block');
151
- });
152
-
153
- it('lässt canonical_name weg wenn name bereits englischer Standardwert ist', async () => {
154
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(enumFixture)));
155
- const result = await mockServer.callTool('get_issue_enums', {});
156
- const parsed = JSON.parse(result.content[0]!.text) as EnumResult;
157
-
158
- // status: names are already English → no canonical_name
159
- const statusNew = parsed.status.find(e => e.id === 10)!;
160
- expect(statusNew.name).toBe('new');
161
- expect(statusNew).not.toHaveProperty('canonical_name');
162
-
163
- // priority: "normal" is already the canonical name
164
- const priorityNormal = parsed.priority.find(e => e.id === 30)!;
165
- expect(priorityNormal.name).toBe('normal');
166
- expect(priorityNormal).not.toHaveProperty('canonical_name');
167
- });
168
-
169
- it('lässt canonical_name weg für unbekannte/benutzerdefinierte IDs', async () => {
170
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(enumFixture)));
171
- const result = await mockServer.callTool('get_issue_enums', {});
172
- const parsed = JSON.parse(result.content[0]!.text) as EnumResult;
173
-
174
- // severity ID 200 ("Technische Schuld") is a custom entry with no canonical mapping
175
- const custom = parsed.severity.find(e => e.id === 200)!;
176
- expect(custom).toBeDefined();
177
- expect(custom).not.toHaveProperty('canonical_name');
178
- });
179
-
180
- it('fügt canonical_name auch im String-Format (Legacy) hinzu', async () => {
181
- // Override: use German names in legacy string format
182
- const germanStringFixture = {
183
- configs: [
184
- { option: 'severity_enum_string', value: '50:kleinerer Fehler,80:Blocker' },
185
- { option: 'status_enum_string', value: '10:new,80:resolved' },
186
- { option: 'priority_enum_string', value: '30:normal' },
187
- { option: 'resolution_enum_string', value: '20:fixed' },
188
- { option: 'reproducibility_enum_string', value: '10:always' },
189
- ],
190
- };
191
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(germanStringFixture)));
192
- const result = await mockServer.callTool('get_issue_enums', {});
193
- const parsed = JSON.parse(result.content[0]!.text) as EnumResult;
194
-
195
- const minor = parsed.severity.find(e => e.id === 50)!;
196
- expect(minor.canonical_name).toBe('minor');
197
-
198
- // status "new" is already canonical — no canonical_name
199
- const statusNew = parsed.status.find(e => e.id === 10)!;
200
- expect(statusNew).not.toHaveProperty('canonical_name');
201
- });
202
- });
203
-
204
- it('gibt isError: true bei API-Fehler zurück', async () => {
205
- vi.mocked(fetch).mockResolvedValue(makeResponse(403, JSON.stringify({ message: 'Access denied' })));
206
-
207
- const result = await mockServer.callTool('get_issue_enums', {});
208
-
209
- expect(result.isError).toBe(true);
210
- expect(result.content[0]!.text).toContain('Error:');
211
- });
212
- });
@@ -1,344 +0,0 @@
1
- import path from 'node:path';
2
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
- import { MantisClient } from '../../src/client.js';
4
- import { registerFileTools } from '../../src/tools/files.js';
5
- import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
6
-
7
- vi.mock('node:fs/promises', () => ({
8
- readFile: vi.fn(),
9
- }));
10
-
11
- // Import after mock so the mock is in place
12
- import { readFile } from 'node:fs/promises';
13
-
14
- // ---------------------------------------------------------------------------
15
- // Setup
16
- // ---------------------------------------------------------------------------
17
-
18
- let mockServer: MockMcpServer;
19
- let client: MantisClient;
20
-
21
- beforeEach(() => {
22
- mockServer = new MockMcpServer();
23
- client = new MantisClient('https://mantis.example.com', 'test-token');
24
- registerFileTools(mockServer as never, client, undefined);
25
- vi.stubGlobal('fetch', vi.fn());
26
- });
27
-
28
- afterEach(() => {
29
- vi.unstubAllGlobals();
30
- vi.clearAllMocks();
31
- });
32
-
33
- // ---------------------------------------------------------------------------
34
- // list_issue_files
35
- // ---------------------------------------------------------------------------
36
-
37
- describe('list_issue_files', () => {
38
- it('is registered', () => {
39
- expect(mockServer.hasToolRegistered('list_issue_files')).toBe(true);
40
- });
41
-
42
- it('returns an attachment array', async () => {
43
- const apiResponse = {
44
- issues: [{
45
- id: 42,
46
- attachments: [
47
- { id: 1, file_name: 'screenshot.png', size: 12345, content_type: 'image/png' },
48
- ],
49
- }],
50
- };
51
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(apiResponse)));
52
-
53
- const result = await mockServer.callTool('list_issue_files', { issue_id: 42 });
54
-
55
- expect(result.isError).toBeUndefined();
56
- const parsed = JSON.parse(result.content[0]!.text) as Array<{ file_name: string }>;
57
- expect(Array.isArray(parsed)).toBe(true);
58
- expect(parsed[0]!.file_name).toBe('screenshot.png');
59
- });
60
-
61
- it('returns an empty array when there are no attachments', async () => {
62
- const apiResponse = { issues: [{ id: 42 }] };
63
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(apiResponse)));
64
-
65
- const result = await mockServer.callTool('list_issue_files', { issue_id: 42 });
66
-
67
- expect(result.isError).toBeUndefined();
68
- const parsed = JSON.parse(result.content[0]!.text) as unknown[];
69
- expect(parsed).toEqual([]);
70
- });
71
-
72
- it('calls the correct endpoint', async () => {
73
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ issues: [{ id: 42 }] })));
74
-
75
- await mockServer.callTool('list_issue_files', { issue_id: 42 });
76
-
77
- const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
78
- expect(calledUrl).toContain('issues/42');
79
- });
80
- });
81
-
82
- // ---------------------------------------------------------------------------
83
- // upload_file – file_path mode
84
- // ---------------------------------------------------------------------------
85
-
86
- describe('upload_file', () => {
87
- it('is registered', () => {
88
- expect(mockServer.hasToolRegistered('upload_file')).toBe(true);
89
- });
90
-
91
- it('reads the file and posts to the correct endpoint', async () => {
92
- vi.mocked(readFile).mockResolvedValue(Buffer.from('file content') as never);
93
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5, file_name: 'report.pdf' })));
94
-
95
- await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/report.pdf' });
96
-
97
- expect(readFile).toHaveBeenCalledWith('/tmp/report.pdf');
98
- const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
99
- expect(calledUrl).toContain('issues/42/files');
100
- });
101
-
102
- it('sends a POST request with FormData', async () => {
103
- vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
104
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
105
-
106
- await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt' });
107
-
108
- const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
109
- expect(options.method).toBe('POST');
110
- expect(options.body).toBeInstanceOf(FormData);
111
- });
112
-
113
- it('does not set Content-Type header (set automatically by fetch)', async () => {
114
- vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
115
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
116
-
117
- await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt' });
118
-
119
- const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
120
- const headers = options.headers as Record<string, string>;
121
- expect(headers['Content-Type']).toBeUndefined();
122
- });
123
-
124
- it('appends description when provided', async () => {
125
- vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
126
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
127
-
128
- await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt', description: 'My attachment' });
129
-
130
- const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
131
- const formData = options.body as FormData;
132
- expect(formData.get('description')).toBe('My attachment');
133
- });
134
-
135
- it('returns the API response', async () => {
136
- vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
137
- const apiResponse = { id: 5, file_name: 'report.pdf', size: 7 };
138
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(apiResponse)));
139
-
140
- const result = await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/report.pdf' });
141
-
142
- expect(result.isError).toBeUndefined();
143
- const parsed = JSON.parse(result.content[0]!.text) as { file_name: string };
144
- expect(parsed.file_name).toBe('report.pdf');
145
- });
146
-
147
- it('returns isError when the file is not found', async () => {
148
- vi.mocked(readFile).mockRejectedValue(new Error("ENOENT: no such file or directory, open '/tmp/missing.txt'") as never);
149
-
150
- const result = await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/missing.txt' });
151
-
152
- expect(result.isError).toBe(true);
153
- expect(result.content[0]!.text).toContain('Error:');
154
- });
155
-
156
- it('returns isError on API error', async () => {
157
- vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
158
- vi.mocked(fetch).mockResolvedValue(makeResponse(403, JSON.stringify({ message: 'Forbidden' })));
159
-
160
- const result = await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt' });
161
-
162
- expect(result.isError).toBe(true);
163
- expect(result.content[0]!.text).toContain('Error:');
164
- });
165
-
166
- it('overrides the filename when filename is provided', async () => {
167
- vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
168
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
169
-
170
- await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/report.pdf', filename: 'custom-name.pdf' });
171
-
172
- const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
173
- const formData = options.body as FormData;
174
- const fileEntry = formData.get('file') as File;
175
- expect(fileEntry.name).toBe('custom-name.pdf');
176
- });
177
- });
178
-
179
- // ---------------------------------------------------------------------------
180
- // upload_file – Base64 mode
181
- // ---------------------------------------------------------------------------
182
-
183
- describe('upload_file (Base64)', () => {
184
- it('decodes Base64 content and uploads it', async () => {
185
- const originalContent = 'Hello, World!';
186
- const base64Content = Buffer.from(originalContent).toString('base64');
187
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 7, file_name: 'hello.txt' })));
188
-
189
- const result = await mockServer.callTool('upload_file', {
190
- issue_id: 42,
191
- content: base64Content,
192
- filename: 'hello.txt',
193
- });
194
-
195
- expect(result.isError).toBeUndefined();
196
- expect(readFile).not.toHaveBeenCalled();
197
- const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
198
- expect(calledUrl).toContain('issues/42/files');
199
- });
200
-
201
- it('uses the provided filename', async () => {
202
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 7 })));
203
-
204
- await mockServer.callTool('upload_file', {
205
- issue_id: 42,
206
- content: Buffer.from('data').toString('base64'),
207
- filename: 'export.csv',
208
- });
209
-
210
- const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
211
- const formData = options.body as FormData;
212
- const fileEntry = formData.get('file') as File;
213
- expect(fileEntry.name).toBe('export.csv');
214
- });
215
-
216
- it('sets the content_type when provided', async () => {
217
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 7 })));
218
-
219
- await mockServer.callTool('upload_file', {
220
- issue_id: 42,
221
- content: Buffer.from('data').toString('base64'),
222
- filename: 'image.png',
223
- content_type: 'image/png',
224
- });
225
-
226
- const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
227
- const formData = options.body as FormData;
228
- const fileEntry = formData.get('file') as File;
229
- expect(fileEntry.type).toBe('image/png');
230
- });
231
-
232
- it('returns isError when neither file_path nor content is provided', async () => {
233
- const result = await mockServer.callTool('upload_file', { issue_id: 42 });
234
-
235
- expect(result.isError).toBe(true);
236
- expect(result.content[0]!.text).toContain('Either file_path or content');
237
- });
238
-
239
- it('returns isError when both file_path and content are provided', async () => {
240
- vi.mocked(readFile).mockResolvedValue(Buffer.from('x') as never);
241
-
242
- const result = await mockServer.callTool('upload_file', {
243
- issue_id: 42,
244
- file_path: '/tmp/test.txt',
245
- content: Buffer.from('x').toString('base64'),
246
- filename: 'test.txt',
247
- });
248
-
249
- expect(result.isError).toBe(true);
250
- expect(result.content[0]!.text).toContain('Only one of');
251
- });
252
-
253
- it('returns isError when content is provided without filename', async () => {
254
- const result = await mockServer.callTool('upload_file', {
255
- issue_id: 42,
256
- content: Buffer.from('data').toString('base64'),
257
- });
258
-
259
- expect(result.isError).toBe(true);
260
- expect(result.content[0]!.text).toContain('filename is required');
261
- });
262
-
263
- it('returns isError on API error', async () => {
264
- vi.mocked(fetch).mockResolvedValue(makeResponse(500, JSON.stringify({ message: 'Internal Server Error' })));
265
-
266
- const result = await mockServer.callTool('upload_file', {
267
- issue_id: 42,
268
- content: Buffer.from('data').toString('base64'),
269
- filename: 'test.txt',
270
- });
271
-
272
- expect(result.isError).toBe(true);
273
- expect(result.content[0]!.text).toContain('Error:');
274
- });
275
- });
276
-
277
- // ---------------------------------------------------------------------------
278
- // upload_file – Path Traversal protection (uploadDir)
279
- // ---------------------------------------------------------------------------
280
-
281
- describe('upload_file (uploadDir restriction)', () => {
282
- const uploadDir = path.resolve('/tmp/uploads');
283
-
284
- beforeEach(() => {
285
- // Override the server registered in the outer beforeEach with one that
286
- // has uploadDir set.
287
- mockServer = new MockMcpServer();
288
- client = new MantisClient('https://mantis.example.com', 'test-token');
289
- registerFileTools(mockServer as never, client, uploadDir);
290
- vi.stubGlobal('fetch', vi.fn());
291
- });
292
-
293
- it('allows file_path inside uploadDir', async () => {
294
- vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
295
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
296
-
297
- const result = await mockServer.callTool('upload_file', {
298
- issue_id: 42,
299
- file_path: path.join(uploadDir, 'report.pdf'),
300
- });
301
-
302
- expect(result.isError).toBeUndefined();
303
- expect(readFile).toHaveBeenCalled();
304
- });
305
-
306
- it('blocks file_path outside uploadDir', async () => {
307
- const result = await mockServer.callTool('upload_file', {
308
- issue_id: 42,
309
- file_path: '/etc/passwd',
310
- });
311
-
312
- expect(result.isError).toBe(true);
313
- expect(result.content[0]!.text).toContain('not allowed');
314
- expect(readFile).not.toHaveBeenCalled();
315
- });
316
-
317
- it('blocks path traversal escaping uploadDir', async () => {
318
- const result = await mockServer.callTool('upload_file', {
319
- issue_id: 42,
320
- file_path: path.join(uploadDir, '..', 'secret.txt'),
321
- });
322
-
323
- expect(result.isError).toBe(true);
324
- expect(result.content[0]!.text).toContain('not allowed');
325
- expect(readFile).not.toHaveBeenCalled();
326
- });
327
-
328
- it('allows any file_path when uploadDir is undefined (no restriction)', async () => {
329
- // This uses the outer beforeEach server (uploadDir = undefined).
330
- const unrestrictedServer = new MockMcpServer();
331
- registerFileTools(unrestrictedServer as never, client, undefined);
332
-
333
- vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
334
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
335
-
336
- const result = await unrestrictedServer.callTool('upload_file', {
337
- issue_id: 42,
338
- file_path: '/etc/passwd',
339
- });
340
-
341
- expect(result.isError).toBeUndefined();
342
- expect(readFile).toHaveBeenCalledWith('/etc/passwd');
343
- });
344
- });