@dpesch/mantisbt-mcp-server 1.10.3 → 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 (43) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +1 -1
  3. package/server.json +2 -2
  4. package/.gitea/PULL_REQUEST_TEMPLATE.md +0 -17
  5. package/.gitea/workflows/ci.yml +0 -95
  6. package/.github/workflows/ci.yml +0 -32
  7. package/scripts/hooks/pre-push.mjs +0 -220
  8. package/scripts/init.mjs +0 -95
  9. package/scripts/record-fixtures.ts +0 -160
  10. package/tests/cache.test.ts +0 -265
  11. package/tests/client.test.ts +0 -372
  12. package/tests/config.test.ts +0 -263
  13. package/tests/fixtures/get_current_user.json +0 -40
  14. package/tests/fixtures/get_issue.json +0 -133
  15. package/tests/fixtures/get_issue_enums.json +0 -67
  16. package/tests/fixtures/get_issue_fields_sample.json +0 -76
  17. package/tests/fixtures/get_project_categories.json +0 -157
  18. package/tests/fixtures/get_project_versions.json +0 -3
  19. package/tests/fixtures/get_project_versions_with_data.json +0 -52
  20. package/tests/fixtures/list_issues.json +0 -95
  21. package/tests/fixtures/list_projects.json +0 -65
  22. package/tests/helpers/mock-server.ts +0 -166
  23. package/tests/helpers/search-mocks.ts +0 -84
  24. package/tests/prompts/prompts.test.ts +0 -242
  25. package/tests/resources/resources.test.ts +0 -309
  26. package/tests/search/embedder.test.ts +0 -81
  27. package/tests/search/highlight.test.ts +0 -129
  28. package/tests/search/store.test.ts +0 -193
  29. package/tests/search/sync.test.ts +0 -249
  30. package/tests/search/tools.test.ts +0 -661
  31. package/tests/tools/config.test.ts +0 -212
  32. package/tests/tools/files.test.ts +0 -343
  33. package/tests/tools/issues.test.ts +0 -1180
  34. package/tests/tools/metadata.test.ts +0 -509
  35. package/tests/tools/monitors.test.ts +0 -101
  36. package/tests/tools/projects.test.ts +0 -338
  37. package/tests/tools/relationships.test.ts +0 -177
  38. package/tests/tools/string-coercion.test.ts +0 -317
  39. package/tests/tools/users.test.ts +0 -62
  40. package/tests/utils/date-filter.test.ts +0 -169
  41. package/tests/version-hint.test.ts +0 -230
  42. package/tsconfig.build.json +0 -8
  43. 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,343 +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 a JSON body', 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
- const body = JSON.parse(options.body as string) as { files: Array<{ name: string; type: string; content: string }> };
111
- expect(Array.isArray(body.files)).toBe(true);
112
- expect(body.files[0]!.name).toBe('test.txt');
113
- });
114
-
115
- it('sets Content-Type header to application/json', async () => {
116
- vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
117
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
118
-
119
- await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt' });
120
-
121
- const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
122
- const headers = options.headers as Record<string, string>;
123
- expect(headers['Content-Type']).toBe('application/json');
124
- });
125
-
126
- it('includes description in JSON body when provided', async () => {
127
- vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
128
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
129
-
130
- await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt', description: 'My attachment' });
131
-
132
- const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
133
- const body = JSON.parse(options.body as string) as { description: string };
134
- expect(body.description).toBe('My attachment');
135
- });
136
-
137
- it('returns the API response', async () => {
138
- vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
139
- const apiResponse = { id: 5, file_name: 'report.pdf', size: 7 };
140
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(apiResponse)));
141
-
142
- const result = await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/report.pdf' });
143
-
144
- expect(result.isError).toBeUndefined();
145
- const parsed = JSON.parse(result.content[0]!.text) as { file_name: string };
146
- expect(parsed.file_name).toBe('report.pdf');
147
- });
148
-
149
- it('returns isError when the file is not found', async () => {
150
- vi.mocked(readFile).mockRejectedValue(new Error("ENOENT: no such file or directory, open '/tmp/missing.txt'") as never);
151
-
152
- const result = await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/missing.txt' });
153
-
154
- expect(result.isError).toBe(true);
155
- expect(result.content[0]!.text).toContain('Error:');
156
- });
157
-
158
- it('returns isError on API error', async () => {
159
- vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
160
- vi.mocked(fetch).mockResolvedValue(makeResponse(403, JSON.stringify({ message: 'Forbidden' })));
161
-
162
- const result = await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt' });
163
-
164
- expect(result.isError).toBe(true);
165
- expect(result.content[0]!.text).toContain('Error:');
166
- });
167
-
168
- it('overrides the filename when filename is provided', async () => {
169
- vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
170
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
171
-
172
- await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/report.pdf', filename: 'custom-name.pdf' });
173
-
174
- const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
175
- const body = JSON.parse(options.body as string) as { files: Array<{ name: string }> };
176
- expect(body.files[0]!.name).toBe('custom-name.pdf');
177
- });
178
- });
179
-
180
- // ---------------------------------------------------------------------------
181
- // upload_file – Base64 mode
182
- // ---------------------------------------------------------------------------
183
-
184
- describe('upload_file (Base64)', () => {
185
- it('decodes Base64 content and uploads it', async () => {
186
- const originalContent = 'Hello, World!';
187
- const base64Content = Buffer.from(originalContent).toString('base64');
188
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 7, file_name: 'hello.txt' })));
189
-
190
- const result = await mockServer.callTool('upload_file', {
191
- issue_id: 42,
192
- content: base64Content,
193
- filename: 'hello.txt',
194
- });
195
-
196
- expect(result.isError).toBeUndefined();
197
- expect(readFile).not.toHaveBeenCalled();
198
- const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
199
- expect(calledUrl).toContain('issues/42/files');
200
- });
201
-
202
- it('uses the provided filename', async () => {
203
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 7 })));
204
-
205
- await mockServer.callTool('upload_file', {
206
- issue_id: 42,
207
- content: Buffer.from('data').toString('base64'),
208
- filename: 'export.csv',
209
- });
210
-
211
- const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
212
- const body = JSON.parse(options.body as string) as { files: Array<{ name: string }> };
213
- expect(body.files[0]!.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 body = JSON.parse(options.body as string) as { files: Array<{ type: string }> };
228
- expect(body.files[0]!.type).toBe('image/png');
229
- });
230
-
231
- it('returns isError when neither file_path nor content is provided', async () => {
232
- const result = await mockServer.callTool('upload_file', { issue_id: 42 });
233
-
234
- expect(result.isError).toBe(true);
235
- expect(result.content[0]!.text).toContain('Either file_path or content');
236
- });
237
-
238
- it('returns isError when both file_path and content are provided', async () => {
239
- vi.mocked(readFile).mockResolvedValue(Buffer.from('x') as never);
240
-
241
- const result = await mockServer.callTool('upload_file', {
242
- issue_id: 42,
243
- file_path: '/tmp/test.txt',
244
- content: Buffer.from('x').toString('base64'),
245
- filename: 'test.txt',
246
- });
247
-
248
- expect(result.isError).toBe(true);
249
- expect(result.content[0]!.text).toContain('Only one of');
250
- });
251
-
252
- it('returns isError when content is provided without filename', async () => {
253
- const result = await mockServer.callTool('upload_file', {
254
- issue_id: 42,
255
- content: Buffer.from('data').toString('base64'),
256
- });
257
-
258
- expect(result.isError).toBe(true);
259
- expect(result.content[0]!.text).toContain('filename is required');
260
- });
261
-
262
- it('returns isError on API error', async () => {
263
- vi.mocked(fetch).mockResolvedValue(makeResponse(500, JSON.stringify({ message: 'Internal Server Error' })));
264
-
265
- const result = await mockServer.callTool('upload_file', {
266
- issue_id: 42,
267
- content: Buffer.from('data').toString('base64'),
268
- filename: 'test.txt',
269
- });
270
-
271
- expect(result.isError).toBe(true);
272
- expect(result.content[0]!.text).toContain('Error:');
273
- });
274
- });
275
-
276
- // ---------------------------------------------------------------------------
277
- // upload_file – Path Traversal protection (uploadDir)
278
- // ---------------------------------------------------------------------------
279
-
280
- describe('upload_file (uploadDir restriction)', () => {
281
- const uploadDir = path.resolve('/tmp/uploads');
282
-
283
- beforeEach(() => {
284
- // Override the server registered in the outer beforeEach with one that
285
- // has uploadDir set.
286
- mockServer = new MockMcpServer();
287
- client = new MantisClient('https://mantis.example.com', 'test-token');
288
- registerFileTools(mockServer as never, client, uploadDir);
289
- vi.stubGlobal('fetch', vi.fn());
290
- });
291
-
292
- it('allows file_path inside uploadDir', async () => {
293
- vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
294
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
295
-
296
- const result = await mockServer.callTool('upload_file', {
297
- issue_id: 42,
298
- file_path: path.join(uploadDir, 'report.pdf'),
299
- });
300
-
301
- expect(result.isError).toBeUndefined();
302
- expect(readFile).toHaveBeenCalled();
303
- });
304
-
305
- it('blocks file_path outside uploadDir', async () => {
306
- const result = await mockServer.callTool('upload_file', {
307
- issue_id: 42,
308
- file_path: '/etc/passwd',
309
- });
310
-
311
- expect(result.isError).toBe(true);
312
- expect(result.content[0]!.text).toContain('not allowed');
313
- expect(readFile).not.toHaveBeenCalled();
314
- });
315
-
316
- it('blocks path traversal escaping uploadDir', async () => {
317
- const result = await mockServer.callTool('upload_file', {
318
- issue_id: 42,
319
- file_path: path.join(uploadDir, '..', 'secret.txt'),
320
- });
321
-
322
- expect(result.isError).toBe(true);
323
- expect(result.content[0]!.text).toContain('not allowed');
324
- expect(readFile).not.toHaveBeenCalled();
325
- });
326
-
327
- it('allows any file_path when uploadDir is undefined (no restriction)', async () => {
328
- // This uses the outer beforeEach server (uploadDir = undefined).
329
- const unrestrictedServer = new MockMcpServer();
330
- registerFileTools(unrestrictedServer as never, client, undefined);
331
-
332
- vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
333
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
334
-
335
- const result = await unrestrictedServer.callTool('upload_file', {
336
- issue_id: 42,
337
- file_path: '/etc/passwd',
338
- });
339
-
340
- expect(result.isError).toBeUndefined();
341
- expect(readFile).toHaveBeenCalledWith('/etc/passwd');
342
- });
343
- });