@dpesch/mantisbt-mcp-server 1.0.3 → 1.2.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/.gitea/PULL_REQUEST_TEMPLATE.md +17 -0
- package/CHANGELOG.md +27 -0
- package/README.de.md +24 -2
- package/README.md +24 -2
- package/dist/cache.js +33 -0
- package/dist/client.js +13 -0
- package/dist/config.js +39 -1
- package/dist/constants.js +3 -0
- package/dist/index.js +5 -0
- package/dist/search/embedder.js +67 -0
- package/dist/search/index.js +19 -0
- package/dist/search/store.js +122 -0
- package/dist/search/sync.js +85 -0
- package/dist/search/tools.js +102 -0
- package/dist/tools/files.js +70 -0
- package/dist/tools/issues.js +16 -2
- package/dist/tools/metadata.js +101 -6
- package/dist/tools/monitors.js +27 -0
- package/dist/tools/relationships.js +29 -0
- package/package.json +4 -1
- package/scripts/record-fixtures.ts +11 -0
- package/tests/cache.test.ts +115 -0
- package/tests/fixtures/get_issue_fields_sample.json +110 -0
- package/tests/fixtures/list_issues.json +34 -0
- package/tests/helpers/mock-server.ts +10 -0
- package/tests/helpers/search-mocks.ts +58 -0
- package/tests/search/store.test.ts +148 -0
- package/tests/search/sync.test.ts +145 -0
- package/tests/search/tools.test.ts +160 -0
- package/tests/tools/files.test.ts +274 -0
- package/tests/tools/issues.test.ts +97 -16
- package/tests/tools/metadata.test.ts +261 -0
- package/tests/tools/monitors.test.ts +101 -0
- package/tests/tools/projects.test.ts +1 -15
- package/tests/tools/relationships.test.ts +102 -0
- package/tests/tools/users.test.ts +1 -15
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { MantisClient } from '../../src/client.js';
|
|
3
|
+
import { registerFileTools } from '../../src/tools/files.js';
|
|
4
|
+
import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
|
|
5
|
+
|
|
6
|
+
vi.mock('node:fs/promises', () => ({
|
|
7
|
+
readFile: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
// Import after mock so the mock is in place
|
|
11
|
+
import { readFile } from 'node:fs/promises';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Setup
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
let mockServer: MockMcpServer;
|
|
18
|
+
let client: MantisClient;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
mockServer = new MockMcpServer();
|
|
22
|
+
client = new MantisClient('https://mantis.example.com', 'test-token');
|
|
23
|
+
registerFileTools(mockServer as never, client);
|
|
24
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.unstubAllGlobals();
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// list_issue_files
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
describe('list_issue_files', () => {
|
|
37
|
+
it('is registered', () => {
|
|
38
|
+
expect(mockServer.hasToolRegistered('list_issue_files')).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns an attachment array', async () => {
|
|
42
|
+
const apiResponse = {
|
|
43
|
+
issues: [{
|
|
44
|
+
id: 42,
|
|
45
|
+
attachments: [
|
|
46
|
+
{ id: 1, file_name: 'screenshot.png', size: 12345, content_type: 'image/png' },
|
|
47
|
+
],
|
|
48
|
+
}],
|
|
49
|
+
};
|
|
50
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(apiResponse)));
|
|
51
|
+
|
|
52
|
+
const result = await mockServer.callTool('list_issue_files', { issue_id: 42 });
|
|
53
|
+
|
|
54
|
+
expect(result.isError).toBeUndefined();
|
|
55
|
+
const parsed = JSON.parse(result.content[0]!.text) as Array<{ file_name: string }>;
|
|
56
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
57
|
+
expect(parsed[0]!.file_name).toBe('screenshot.png');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns an empty array when there are no attachments', async () => {
|
|
61
|
+
const apiResponse = { issues: [{ id: 42 }] };
|
|
62
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(apiResponse)));
|
|
63
|
+
|
|
64
|
+
const result = await mockServer.callTool('list_issue_files', { issue_id: 42 });
|
|
65
|
+
|
|
66
|
+
expect(result.isError).toBeUndefined();
|
|
67
|
+
const parsed = JSON.parse(result.content[0]!.text) as unknown[];
|
|
68
|
+
expect(parsed).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('calls the correct endpoint', async () => {
|
|
72
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ issues: [{ id: 42 }] })));
|
|
73
|
+
|
|
74
|
+
await mockServer.callTool('list_issue_files', { issue_id: 42 });
|
|
75
|
+
|
|
76
|
+
const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
|
|
77
|
+
expect(calledUrl).toContain('issues/42');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// upload_file – file_path mode
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
describe('upload_file', () => {
|
|
86
|
+
it('is registered', () => {
|
|
87
|
+
expect(mockServer.hasToolRegistered('upload_file')).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('reads the file and posts to the correct endpoint', async () => {
|
|
91
|
+
vi.mocked(readFile).mockResolvedValue(Buffer.from('file content') as never);
|
|
92
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5, file_name: 'report.pdf' })));
|
|
93
|
+
|
|
94
|
+
await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/report.pdf' });
|
|
95
|
+
|
|
96
|
+
expect(readFile).toHaveBeenCalledWith('/tmp/report.pdf');
|
|
97
|
+
const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
|
|
98
|
+
expect(calledUrl).toContain('issues/42/files');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('sends a POST request with FormData', async () => {
|
|
102
|
+
vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
|
|
103
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
|
|
104
|
+
|
|
105
|
+
await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt' });
|
|
106
|
+
|
|
107
|
+
const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
|
|
108
|
+
expect(options.method).toBe('POST');
|
|
109
|
+
expect(options.body).toBeInstanceOf(FormData);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('does not set Content-Type header (set automatically by fetch)', async () => {
|
|
113
|
+
vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
|
|
114
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
|
|
115
|
+
|
|
116
|
+
await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt' });
|
|
117
|
+
|
|
118
|
+
const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
|
|
119
|
+
const headers = options.headers as Record<string, string>;
|
|
120
|
+
expect(headers['Content-Type']).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('appends description when provided', async () => {
|
|
124
|
+
vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
|
|
125
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
|
|
126
|
+
|
|
127
|
+
await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt', description: 'My attachment' });
|
|
128
|
+
|
|
129
|
+
const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
|
|
130
|
+
const formData = options.body as FormData;
|
|
131
|
+
expect(formData.get('description')).toBe('My attachment');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('returns the API response', async () => {
|
|
135
|
+
vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
|
|
136
|
+
const apiResponse = { id: 5, file_name: 'report.pdf', size: 7 };
|
|
137
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(apiResponse)));
|
|
138
|
+
|
|
139
|
+
const result = await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/report.pdf' });
|
|
140
|
+
|
|
141
|
+
expect(result.isError).toBeUndefined();
|
|
142
|
+
const parsed = JSON.parse(result.content[0]!.text) as { file_name: string };
|
|
143
|
+
expect(parsed.file_name).toBe('report.pdf');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('returns isError when the file is not found', async () => {
|
|
147
|
+
vi.mocked(readFile).mockRejectedValue(new Error("ENOENT: no such file or directory, open '/tmp/missing.txt'") as never);
|
|
148
|
+
|
|
149
|
+
const result = await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/missing.txt' });
|
|
150
|
+
|
|
151
|
+
expect(result.isError).toBe(true);
|
|
152
|
+
expect(result.content[0]!.text).toContain('Error:');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('returns isError on API error', async () => {
|
|
156
|
+
vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
|
|
157
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(403, JSON.stringify({ message: 'Forbidden' })));
|
|
158
|
+
|
|
159
|
+
const result = await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt' });
|
|
160
|
+
|
|
161
|
+
expect(result.isError).toBe(true);
|
|
162
|
+
expect(result.content[0]!.text).toContain('Error:');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('overrides the filename when filename is provided', async () => {
|
|
166
|
+
vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
|
|
167
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
|
|
168
|
+
|
|
169
|
+
await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/report.pdf', filename: 'custom-name.pdf' });
|
|
170
|
+
|
|
171
|
+
const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
|
|
172
|
+
const formData = options.body as FormData;
|
|
173
|
+
const fileEntry = formData.get('file') as File;
|
|
174
|
+
expect(fileEntry.name).toBe('custom-name.pdf');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// upload_file – Base64 mode
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
describe('upload_file (Base64)', () => {
|
|
183
|
+
it('decodes Base64 content and uploads it', async () => {
|
|
184
|
+
const originalContent = 'Hello, World!';
|
|
185
|
+
const base64Content = Buffer.from(originalContent).toString('base64');
|
|
186
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 7, file_name: 'hello.txt' })));
|
|
187
|
+
|
|
188
|
+
const result = await mockServer.callTool('upload_file', {
|
|
189
|
+
issue_id: 42,
|
|
190
|
+
content: base64Content,
|
|
191
|
+
filename: 'hello.txt',
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(result.isError).toBeUndefined();
|
|
195
|
+
expect(readFile).not.toHaveBeenCalled();
|
|
196
|
+
const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
|
|
197
|
+
expect(calledUrl).toContain('issues/42/files');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('uses the provided filename', async () => {
|
|
201
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 7 })));
|
|
202
|
+
|
|
203
|
+
await mockServer.callTool('upload_file', {
|
|
204
|
+
issue_id: 42,
|
|
205
|
+
content: Buffer.from('data').toString('base64'),
|
|
206
|
+
filename: 'export.csv',
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
|
|
210
|
+
const formData = options.body as FormData;
|
|
211
|
+
const fileEntry = formData.get('file') as File;
|
|
212
|
+
expect(fileEntry.name).toBe('export.csv');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('sets the content_type when provided', async () => {
|
|
216
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 7 })));
|
|
217
|
+
|
|
218
|
+
await mockServer.callTool('upload_file', {
|
|
219
|
+
issue_id: 42,
|
|
220
|
+
content: Buffer.from('data').toString('base64'),
|
|
221
|
+
filename: 'image.png',
|
|
222
|
+
content_type: 'image/png',
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
|
|
226
|
+
const formData = options.body as FormData;
|
|
227
|
+
const fileEntry = formData.get('file') as File;
|
|
228
|
+
expect(fileEntry.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
|
+
});
|
|
@@ -4,7 +4,8 @@ import { join, dirname } from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { MantisClient } from '../../src/client.js';
|
|
6
6
|
import { registerIssueTools } from '../../src/tools/issues.js';
|
|
7
|
-
import { MockMcpServer } from '../helpers/mock-server.js';
|
|
7
|
+
import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
|
|
8
|
+
import { MANTIS_RESOLVED_STATUS_ID } from '../../src/constants.js';
|
|
8
9
|
|
|
9
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
11
|
const __dirname = dirname(__filename);
|
|
@@ -22,22 +23,13 @@ const getIssueFixture = existsSync(getIssueFixturePath)
|
|
|
22
23
|
: { issues: [{ id: 42, summary: 'Test Issue' }] };
|
|
23
24
|
|
|
24
25
|
const listIssuesFixture = existsSync(listIssuesFixturePath)
|
|
25
|
-
? (JSON.parse(readFileSync(listIssuesFixturePath, 'utf-8')) as { issues: Array<{ id: number; summary: string }>; total_count: number })
|
|
26
|
-
: { issues: [{ id: 42, summary: 'Test Issue' }], total_count: 1 };
|
|
26
|
+
? (JSON.parse(readFileSync(listIssuesFixturePath, 'utf-8')) as { issues: Array<{ id: number; summary: string; status: { id: number; name: string } }>; total_count: number })
|
|
27
|
+
: { issues: [{ id: 42, summary: 'Test Issue', status: { id: 80, name: 'resolved' } }], total_count: 1 };
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
function makeResponse(status: number, body: string): Response {
|
|
33
|
-
return {
|
|
34
|
-
ok: status >= 200 && status < 300,
|
|
35
|
-
status,
|
|
36
|
-
statusText: `Status ${status}`,
|
|
37
|
-
text: () => Promise.resolve(body),
|
|
38
|
-
headers: { get: (_key: string) => null },
|
|
39
|
-
} as unknown as Response;
|
|
40
|
-
}
|
|
29
|
+
const recordedListIssuesPath = join(fixturesDir, 'recorded', 'list_issues.json');
|
|
30
|
+
const recordedListIssuesFixture = existsSync(recordedListIssuesPath)
|
|
31
|
+
? (JSON.parse(readFileSync(recordedListIssuesPath, 'utf-8')) as { issues: Array<{ id: number; summary: string; status: { id: number; name: string } }> })
|
|
32
|
+
: null;
|
|
41
33
|
|
|
42
34
|
// ---------------------------------------------------------------------------
|
|
43
35
|
// Setup
|
|
@@ -127,4 +119,93 @@ describe('list_issues', () => {
|
|
|
127
119
|
const url = new URL(calledUrl);
|
|
128
120
|
expect(url.searchParams.get('project_id')).toBe('7');
|
|
129
121
|
});
|
|
122
|
+
|
|
123
|
+
it('passes select as query parameter', async () => {
|
|
124
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
125
|
+
|
|
126
|
+
await mockServer.callTool('list_issues', { select: 'id,summary,status', page: 1, page_size: 10 });
|
|
127
|
+
|
|
128
|
+
const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
|
|
129
|
+
const url = new URL(calledUrl);
|
|
130
|
+
expect(url.searchParams.get('select')).toBe('id,summary,status');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('status "open" filters to issues with status.id < 80', async () => {
|
|
134
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
135
|
+
|
|
136
|
+
const result = await mockServer.callTool('list_issues', { status: 'open', page: 1, page_size: 50 });
|
|
137
|
+
|
|
138
|
+
expect(result.isError).toBeUndefined();
|
|
139
|
+
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ status: { id: number } }> };
|
|
140
|
+
expect(parsed.issues.length).toBeGreaterThan(0);
|
|
141
|
+
parsed.issues.forEach(issue => {
|
|
142
|
+
expect(issue.status.id).toBeLessThan(MANTIS_RESOLVED_STATUS_ID);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('status "resolved" filters to resolved issues only', async () => {
|
|
147
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
148
|
+
|
|
149
|
+
const result = await mockServer.callTool('list_issues', { status: 'resolved', page: 1, page_size: 50 });
|
|
150
|
+
|
|
151
|
+
expect(result.isError).toBeUndefined();
|
|
152
|
+
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ status: { name: string } }> };
|
|
153
|
+
expect(parsed.issues.length).toBeGreaterThan(0);
|
|
154
|
+
parsed.issues.forEach(issue => {
|
|
155
|
+
expect(issue.status.name).toBe('resolved');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('status "new" filters to new issues only', async () => {
|
|
160
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
161
|
+
|
|
162
|
+
const result = await mockServer.callTool('list_issues', { status: 'new', page: 1, page_size: 50 });
|
|
163
|
+
|
|
164
|
+
expect(result.isError).toBeUndefined();
|
|
165
|
+
const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number; status: { name: string } }> };
|
|
166
|
+
expect(parsed.issues).toHaveLength(1);
|
|
167
|
+
expect(parsed.issues[0]!.id).toBe(7901);
|
|
168
|
+
expect(parsed.issues[0]!.status.name).toBe('new');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('status filter is case-insensitive', async () => {
|
|
172
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
173
|
+
const resultUpper = await mockServer.callTool('list_issues', { status: 'OPEN', page: 1, page_size: 50 });
|
|
174
|
+
|
|
175
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
|
|
176
|
+
const resultLower = await mockServer.callTool('list_issues', { status: 'open', page: 1, page_size: 50 });
|
|
177
|
+
|
|
178
|
+
const parsedUpper = JSON.parse(resultUpper.content[0]!.text) as { issues: unknown[] };
|
|
179
|
+
const parsedLower = JSON.parse(resultLower.content[0]!.text) as { issues: unknown[] };
|
|
180
|
+
expect(parsedUpper.issues.length).toBe(parsedLower.issues.length);
|
|
181
|
+
expect(parsedUpper.issues).toEqual(parsedLower.issues);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// list_issues – recorded fixtures
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
describe('list_issues – recorded fixtures', () => {
|
|
190
|
+
it.skipIf(!recordedListIssuesFixture)('status "open" filter matches open issues in recorded fixture', async () => {
|
|
191
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(recordedListIssuesFixture!)));
|
|
192
|
+
|
|
193
|
+
const result = await mockServer.callTool('list_issues', { status: 'open', page: 1, page_size: 50 });
|
|
194
|
+
|
|
195
|
+
expect(result.isError).toBeUndefined();
|
|
196
|
+
const parsed = JSON.parse(result.content[0]!.text) as { issues: unknown[] };
|
|
197
|
+
const openInFixture = recordedListIssuesFixture!.issues.filter(i => i.status.id < 80).length;
|
|
198
|
+
expect(parsed.issues).toHaveLength(openInFixture);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it.skipIf(!recordedListIssuesFixture)('status "resolved" filter matches resolved issues in recorded fixture', async () => {
|
|
202
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(recordedListIssuesFixture!)));
|
|
203
|
+
|
|
204
|
+
const result = await mockServer.callTool('list_issues', { status: 'resolved', page: 1, page_size: 50 });
|
|
205
|
+
|
|
206
|
+
expect(result.isError).toBeUndefined();
|
|
207
|
+
const parsed = JSON.parse(result.content[0]!.text) as { issues: unknown[] };
|
|
208
|
+
const resolvedInFixture = recordedListIssuesFixture!.issues.filter(i => i.status.id >= 80).length;
|
|
209
|
+
expect(parsed.issues).toHaveLength(resolvedInFixture);
|
|
210
|
+
});
|
|
130
211
|
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
// vi.mock must be at module top level — vitest hoists it automatically
|
|
7
|
+
vi.mock('node:fs/promises');
|
|
8
|
+
|
|
9
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
10
|
+
import { MantisClient } from '../../src/client.js';
|
|
11
|
+
import { MetadataCache, type CachedMetadata } from '../../src/cache.js';
|
|
12
|
+
import { registerMetadataTools } from '../../src/tools/metadata.js';
|
|
13
|
+
import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = dirname(__filename);
|
|
17
|
+
const recordedFixturesDir = join(__dirname, '..', 'fixtures', 'recorded');
|
|
18
|
+
|
|
19
|
+
// Recorded fixture: single issue returned by issues?page=1&page_size=1
|
|
20
|
+
const sampleFixturePath = join(recordedFixturesDir, 'get_issue_fields_sample.json');
|
|
21
|
+
const recordedSampleFixture = existsSync(sampleFixturePath)
|
|
22
|
+
? (JSON.parse(readFileSync(sampleFixturePath, 'utf-8')) as { issues: Array<Record<string, unknown>> })
|
|
23
|
+
: null;
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Helpers
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
const CACHE_DIR = '/tmp/test-cache-metadata';
|
|
30
|
+
const TTL = 3600;
|
|
31
|
+
|
|
32
|
+
function makeServer(): MockMcpServer {
|
|
33
|
+
return new MockMcpServer();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeClient(): MantisClient {
|
|
37
|
+
return new MantisClient('https://mantis.example.com', 'test-token');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeCache(): MetadataCache {
|
|
41
|
+
return new MetadataCache(CACHE_DIR, TTL);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeSampleMetadata(): CachedMetadata {
|
|
45
|
+
return {
|
|
46
|
+
timestamp: Date.now(),
|
|
47
|
+
projects: [{ id: 1, name: 'Test Project' }],
|
|
48
|
+
byProject: {},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeValidMetadataCacheFile(data: CachedMetadata): string {
|
|
53
|
+
return JSON.stringify({ timestamp: Date.now(), data });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Setup
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
let mockServer: MockMcpServer;
|
|
61
|
+
let client: MantisClient;
|
|
62
|
+
let cache: MetadataCache;
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
vi.resetAllMocks();
|
|
66
|
+
mockServer = makeServer();
|
|
67
|
+
client = makeClient();
|
|
68
|
+
cache = makeCache();
|
|
69
|
+
registerMetadataTools(mockServer as never, client, cache);
|
|
70
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
vi.unstubAllGlobals();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// get_metadata
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
describe('get_metadata', () => {
|
|
82
|
+
it('is registered', () => {
|
|
83
|
+
expect(mockServer.hasToolRegistered('get_metadata')).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('loads from cache when valid (single file read — no re-fetch)', async () => {
|
|
87
|
+
const metadata = makeSampleMetadata();
|
|
88
|
+
vi.mocked(readFile).mockResolvedValue(makeValidMetadataCacheFile(metadata) as any);
|
|
89
|
+
|
|
90
|
+
const result = await mockServer.callTool('get_metadata', {});
|
|
91
|
+
|
|
92
|
+
expect(result.isError).toBeUndefined();
|
|
93
|
+
// fetch must NOT have been called — data came from cache
|
|
94
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
95
|
+
const parsed = JSON.parse(result.content[0]!.text) as CachedMetadata;
|
|
96
|
+
expect(parsed.projects).toEqual(metadata.projects);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('fetches and caches when cache is missing', async () => {
|
|
100
|
+
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
|
|
101
|
+
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
102
|
+
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
103
|
+
|
|
104
|
+
const projectsResponse = { projects: [{ id: 1, name: 'Test Project' }] };
|
|
105
|
+
const emptyResponse = { users: [], versions: [], categories: [] };
|
|
106
|
+
vi.mocked(fetch)
|
|
107
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify(projectsResponse)))
|
|
108
|
+
.mockResolvedValue(makeResponse(200, JSON.stringify(emptyResponse)));
|
|
109
|
+
|
|
110
|
+
const result = await mockServer.callTool('get_metadata', {});
|
|
111
|
+
|
|
112
|
+
expect(result.isError).toBeUndefined();
|
|
113
|
+
expect(fetch).toHaveBeenCalled();
|
|
114
|
+
expect(writeFile).toHaveBeenCalled();
|
|
115
|
+
const writtenPath = vi.mocked(writeFile).mock.calls[0]![0] as string;
|
|
116
|
+
expect(writtenPath).toContain('metadata.json');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// get_issue_fields
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe('get_issue_fields', () => {
|
|
125
|
+
it('is registered', () => {
|
|
126
|
+
expect(mockServer.hasToolRegistered('get_issue_fields')).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns cached fields when cache is valid', async () => {
|
|
130
|
+
const cachedFields = ['id', 'summary', 'status', 'priority', 'attachments'];
|
|
131
|
+
const cacheFile = JSON.stringify({ timestamp: Date.now(), fields: cachedFields });
|
|
132
|
+
|
|
133
|
+
// loadIssueFields reads from issue_fields.json
|
|
134
|
+
vi.mocked(readFile).mockImplementation(async (path) => {
|
|
135
|
+
if (String(path).includes('issue_fields.json')) {
|
|
136
|
+
return cacheFile as any;
|
|
137
|
+
}
|
|
138
|
+
throw new Error('ENOENT');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const result = await mockServer.callTool('get_issue_fields', {});
|
|
142
|
+
|
|
143
|
+
expect(result.isError).toBeUndefined();
|
|
144
|
+
// fetch must NOT have been called — data came from cache
|
|
145
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
146
|
+
const parsed = JSON.parse(result.content[0]!.text) as { fields: string[]; source: string };
|
|
147
|
+
expect(parsed.source).toBe('cache');
|
|
148
|
+
expect(parsed.fields).toEqual(cachedFields);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('fetches sample issue and discovers fields', async () => {
|
|
152
|
+
// Cache miss
|
|
153
|
+
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
|
|
154
|
+
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
155
|
+
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
156
|
+
|
|
157
|
+
const sampleIssue = {
|
|
158
|
+
id: 42,
|
|
159
|
+
summary: 'Sample',
|
|
160
|
+
status: { id: 10, name: 'new' },
|
|
161
|
+
priority: { id: 30, name: 'normal' },
|
|
162
|
+
reporter: { id: 1, name: 'user' },
|
|
163
|
+
};
|
|
164
|
+
const issuesResponse = { issues: [sampleIssue] };
|
|
165
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(issuesResponse)));
|
|
166
|
+
|
|
167
|
+
const result = await mockServer.callTool('get_issue_fields', {});
|
|
168
|
+
|
|
169
|
+
expect(result.isError).toBeUndefined();
|
|
170
|
+
const parsed = JSON.parse(result.content[0]!.text) as { fields: string[]; source: string };
|
|
171
|
+
expect(parsed.source).toBe('live');
|
|
172
|
+
// Fields from sample issue
|
|
173
|
+
expect(parsed.fields).toContain('id');
|
|
174
|
+
expect(parsed.fields).toContain('summary');
|
|
175
|
+
expect(parsed.fields).toContain('status');
|
|
176
|
+
// Fields from EMPTY_STRIPPED_FIELDS that are always merged in
|
|
177
|
+
expect(parsed.fields).toContain('attachments');
|
|
178
|
+
expect(parsed.fields).toContain('notes');
|
|
179
|
+
expect(parsed.fields).toContain('relationships');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('falls back to static list when no issues available', async () => {
|
|
183
|
+
// Cache miss
|
|
184
|
+
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
|
|
185
|
+
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
186
|
+
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
187
|
+
|
|
188
|
+
const emptyIssuesResponse = { issues: [] };
|
|
189
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(emptyIssuesResponse)));
|
|
190
|
+
|
|
191
|
+
const result = await mockServer.callTool('get_issue_fields', {});
|
|
192
|
+
|
|
193
|
+
expect(result.isError).toBeUndefined();
|
|
194
|
+
const parsed = JSON.parse(result.content[0]!.text) as { fields: string[]; source: string };
|
|
195
|
+
expect(parsed.source).toBe('static');
|
|
196
|
+
// Static list must contain common fields
|
|
197
|
+
expect(parsed.fields).toContain('id');
|
|
198
|
+
expect(parsed.fields).toContain('summary');
|
|
199
|
+
expect(parsed.fields).toContain('status');
|
|
200
|
+
expect(parsed.fields).toContain('attachments');
|
|
201
|
+
expect(parsed.fields).toContain('notes');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('caches discovered fields after fetching', async () => {
|
|
205
|
+
// Cache miss
|
|
206
|
+
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
|
|
207
|
+
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
208
|
+
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
209
|
+
|
|
210
|
+
const sampleIssue = { id: 1, summary: 'Test', status: { id: 10, name: 'new' } };
|
|
211
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ issues: [sampleIssue] })));
|
|
212
|
+
|
|
213
|
+
await mockServer.callTool('get_issue_fields', {});
|
|
214
|
+
|
|
215
|
+
// writeFile must have been called with issue_fields.json path
|
|
216
|
+
expect(writeFile).toHaveBeenCalled();
|
|
217
|
+
const writtenPath = vi.mocked(writeFile).mock.calls.find(
|
|
218
|
+
call => String(call[0]).includes('issue_fields.json')
|
|
219
|
+
);
|
|
220
|
+
expect(writtenPath).toBeDefined();
|
|
221
|
+
|
|
222
|
+
// Written content must be valid JSON with a fields array
|
|
223
|
+
const writtenContent = writtenPath![1] as string;
|
|
224
|
+
const parsed = JSON.parse(writtenContent) as { timestamp: number; fields: string[] };
|
|
225
|
+
expect(Array.isArray(parsed.fields)).toBe(true);
|
|
226
|
+
expect(parsed.fields.length).toBeGreaterThan(0);
|
|
227
|
+
expect(typeof parsed.timestamp).toBe('number');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// get_issue_fields – recorded fixtures
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
describe('get_issue_fields – recorded fixtures', () => {
|
|
236
|
+
it.skipIf(!recordedSampleFixture)('discovers fields from recorded sample issue', async () => {
|
|
237
|
+
// Cache miss — force live discovery
|
|
238
|
+
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
|
|
239
|
+
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
240
|
+
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
241
|
+
|
|
242
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(recordedSampleFixture!)));
|
|
243
|
+
|
|
244
|
+
const result = await mockServer.callTool('get_issue_fields', {});
|
|
245
|
+
|
|
246
|
+
expect(result.isError).toBeUndefined();
|
|
247
|
+
const parsed = JSON.parse(result.content[0]!.text) as { fields: string[]; source: string };
|
|
248
|
+
expect(parsed.source).toBe('live');
|
|
249
|
+
|
|
250
|
+
// Every top-level key of the recorded sample issue must be in the discovered fields
|
|
251
|
+
const sampleKeys = Object.keys(recordedSampleFixture!.issues[0]!);
|
|
252
|
+
for (const key of sampleKeys) {
|
|
253
|
+
expect(parsed.fields).toContain(key);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// EMPTY_STRIPPED_FIELDS must always be present even if absent from the sample
|
|
257
|
+
expect(parsed.fields).toContain('attachments');
|
|
258
|
+
expect(parsed.fields).toContain('notes');
|
|
259
|
+
expect(parsed.fields).toContain('relationships');
|
|
260
|
+
});
|
|
261
|
+
});
|