@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,509 +0,0 @@
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
- tags: [],
50
- };
51
- }
52
-
53
- function makeValidMetadataCacheFile(data: CachedMetadata): string {
54
- return JSON.stringify({ timestamp: Date.now(), data });
55
- }
56
-
57
- // ---------------------------------------------------------------------------
58
- // Setup
59
- // ---------------------------------------------------------------------------
60
-
61
- let mockServer: MockMcpServer;
62
- let client: MantisClient;
63
- let cache: MetadataCache;
64
-
65
- beforeEach(() => {
66
- vi.resetAllMocks();
67
- mockServer = makeServer();
68
- client = makeClient();
69
- cache = makeCache();
70
- registerMetadataTools(mockServer as never, client, cache);
71
- vi.stubGlobal('fetch', vi.fn());
72
- });
73
-
74
- afterEach(() => {
75
- vi.unstubAllGlobals();
76
- });
77
-
78
- // ---------------------------------------------------------------------------
79
- // get_metadata
80
- // ---------------------------------------------------------------------------
81
-
82
- describe('get_metadata', () => {
83
- it('is registered', () => {
84
- expect(mockServer.hasToolRegistered('get_metadata')).toBe(true);
85
- });
86
-
87
- it('loads from cache when valid (single file read — no re-fetch)', async () => {
88
- const metadata = makeSampleMetadata();
89
- vi.mocked(readFile).mockResolvedValue(makeValidMetadataCacheFile(metadata) as any);
90
-
91
- const result = await mockServer.callTool('get_metadata', {});
92
-
93
- expect(result.isError).toBeUndefined();
94
- // fetch must NOT have been called — data came from cache
95
- expect(fetch).not.toHaveBeenCalled();
96
- const parsed = JSON.parse(result.content[0]!.text) as { projects: number };
97
- expect(parsed.projects).toBe(1);
98
- });
99
-
100
- it('returns compact summary (no raw arrays)', async () => {
101
- const metadata = makeSampleMetadata();
102
- vi.mocked(readFile).mockResolvedValue(makeValidMetadataCacheFile(metadata) as any);
103
-
104
- const result = await mockServer.callTool('get_metadata', {});
105
- const parsed = JSON.parse(result.content[0]!.text) as Record<string, unknown>;
106
- expect(typeof parsed['projects']).toBe('number');
107
- expect(typeof parsed['tags']).toBe('number');
108
- expect(Array.isArray(parsed['projects'])).toBe(false);
109
- });
110
-
111
- it('byProject contains name and counts per project', async () => {
112
- const metadata: CachedMetadata = {
113
- timestamp: Date.now(),
114
- projects: [{ id: 1, name: 'Test Project' }],
115
- byProject: { 1: { users: [{ id: 1, name: 'u' }], versions: [{ id: 1, name: 'v1' }], categories: [{ id: 1, name: 'c' }, { id: 2, name: 'c2' }] } },
116
- tags: [],
117
- };
118
- vi.mocked(readFile).mockResolvedValue(makeValidMetadataCacheFile(metadata) as any);
119
-
120
- const result = await mockServer.callTool('get_metadata', {});
121
- const parsed = JSON.parse(result.content[0]!.text) as { byProject: Record<string, { name: string; users: number; versions: number; categories: number }> };
122
- const entry = Object.values(parsed.byProject)[0]!;
123
- expect(typeof entry.name).toBe('string');
124
- expect(typeof entry.users).toBe('number');
125
- expect(typeof entry.versions).toBe('number');
126
- expect(typeof entry.categories).toBe('number');
127
- });
128
-
129
- it('ttl_seconds is positive for fresh cache', async () => {
130
- const metadata = makeSampleMetadata();
131
- vi.mocked(readFile).mockResolvedValue(makeValidMetadataCacheFile(metadata) as any);
132
-
133
- const result = await mockServer.callTool('get_metadata', {});
134
- const parsed = JSON.parse(result.content[0]!.text) as { ttl_seconds: number };
135
- expect(parsed.ttl_seconds).toBeGreaterThan(0);
136
- });
137
-
138
- it('fetches and caches when cache is missing', async () => {
139
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
140
- vi.mocked(mkdir).mockResolvedValue(undefined);
141
- vi.mocked(writeFile).mockResolvedValue(undefined);
142
-
143
- const projectsResponse = { projects: [{ id: 1, name: 'Test Project' }] };
144
- const emptyResponse = { users: [], versions: [], categories: [] };
145
- vi.mocked(fetch)
146
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify(projectsResponse)))
147
- .mockResolvedValue(makeResponse(200, JSON.stringify(emptyResponse)));
148
-
149
- const result = await mockServer.callTool('get_metadata', {});
150
-
151
- expect(result.isError).toBeUndefined();
152
- expect(fetch).toHaveBeenCalled();
153
- expect(writeFile).toHaveBeenCalled();
154
- const writtenPath = vi.mocked(writeFile).mock.calls[0]![0] as string;
155
- expect(writtenPath).toContain('metadata.json');
156
- });
157
- });
158
-
159
- // ---------------------------------------------------------------------------
160
- // get_metadata_full
161
- // ---------------------------------------------------------------------------
162
-
163
- describe('get_metadata_full', () => {
164
- it('is registered', () => {
165
- expect(mockServer.hasToolRegistered('get_metadata_full')).toBe(true);
166
- });
167
-
168
- it('returns full raw cache (arrays, not counts)', async () => {
169
- const metadata = makeSampleMetadata();
170
- vi.mocked(readFile).mockResolvedValue(makeValidMetadataCacheFile(metadata) as any);
171
-
172
- const result = await mockServer.callTool('get_metadata_full', {});
173
- const parsed = JSON.parse(result.content[0]!.text) as { projects: unknown[]; tags: unknown[] };
174
- expect(Array.isArray(parsed.projects)).toBe(true);
175
- expect(Array.isArray(parsed.tags)).toBe(true);
176
- });
177
-
178
- it('returns minified JSON (no newlines)', async () => {
179
- const metadata = makeSampleMetadata();
180
- vi.mocked(readFile).mockResolvedValue(makeValidMetadataCacheFile(metadata) as any);
181
-
182
- const result = await mockServer.callTool('get_metadata_full', {});
183
- expect(result.content[0]!.text).not.toContain('\n');
184
- });
185
-
186
- it('uses cache without fetching', async () => {
187
- const metadata = makeSampleMetadata();
188
- vi.mocked(readFile).mockResolvedValue(makeValidMetadataCacheFile(metadata) as any);
189
-
190
- await mockServer.callTool('get_metadata_full', {});
191
- expect(fetch).not.toHaveBeenCalled();
192
- });
193
- });
194
-
195
- // ---------------------------------------------------------------------------
196
- // sync_metadata
197
- // ---------------------------------------------------------------------------
198
-
199
- describe('sync_metadata', () => {
200
- it('is registered', () => {
201
- expect(mockServer.hasToolRegistered('sync_metadata')).toBe(true);
202
- });
203
-
204
- it('fetches and stores tags at root level (#7860)', async () => {
205
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
206
- vi.mocked(mkdir).mockResolvedValue(undefined);
207
- vi.mocked(writeFile).mockResolvedValue(undefined);
208
-
209
- const projectsResponse = { projects: [{ id: 1, name: 'Test Project' }] };
210
- const tagsResponse = { tags: [{ id: 1, name: 'regression' }, { id: 2, name: 'ui' }] };
211
-
212
- vi.mocked(fetch)
213
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify(projectsResponse))) // GET /projects
214
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify({ users: [] }))) // GET /projects/1/users
215
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify({ versions: [] }))) // GET /projects/1/versions
216
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify({ projects: [{ id: 1, categories: [] }] }))) // GET /projects/1
217
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify(tagsResponse))); // GET /tags
218
-
219
- const result = await mockServer.callTool('sync_metadata', {});
220
-
221
- expect(result.isError).toBeUndefined();
222
- // Tags count must appear in the success message
223
- expect(result.content[0]!.text).toContain('Global tags: 2');
224
-
225
- // The written metadata.json must contain tags at root level
226
- const writeCall = vi.mocked(writeFile).mock.calls.find(
227
- call => String(call[0]).includes('metadata.json')
228
- );
229
- expect(writeCall).toBeDefined();
230
- const written = JSON.parse(writeCall![1] as string) as { data: { tags: unknown[] } };
231
- expect(Array.isArray(written.data.tags)).toBe(true);
232
- expect(written.data.tags).toHaveLength(2);
233
- });
234
-
235
- it('strips custom_fields from projects before writing to cache', async () => {
236
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
237
- vi.mocked(mkdir).mockResolvedValue(undefined);
238
- vi.mocked(writeFile).mockResolvedValue(undefined);
239
-
240
- const projectsResponse = {
241
- projects: [{
242
- id: 1,
243
- name: 'Test Project',
244
- status: { id: 10, name: 'development', label: 'Entwicklung' },
245
- custom_fields: [{ id: 6, name: 'Reklamieren', type: 'checkbox' }],
246
- }],
247
- };
248
-
249
- vi.mocked(fetch)
250
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify(projectsResponse)))
251
- .mockResolvedValue(makeResponse(200, JSON.stringify({ users: [], versions: [], projects: [{ id: 1, categories: [] }], tags: [] })));
252
-
253
- await mockServer.callTool('sync_metadata', {});
254
-
255
- const writeCall = vi.mocked(writeFile).mock.calls.find(call => String(call[0]).includes('metadata.json'));
256
- const written = JSON.parse(writeCall![1] as string) as { data: { projects: Array<Record<string, unknown>> } };
257
- expect(written.data.projects[0]).not.toHaveProperty('custom_fields');
258
- });
259
-
260
- it('preserves label on status and view_state when writing to cache', async () => {
261
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
262
- vi.mocked(mkdir).mockResolvedValue(undefined);
263
- vi.mocked(writeFile).mockResolvedValue(undefined);
264
-
265
- const projectsResponse = {
266
- projects: [{
267
- id: 1,
268
- name: 'Test Project',
269
- status: { id: 10, name: 'development', label: 'Entwicklung' },
270
- view_state: { id: 10, name: 'public', label: 'Öffentlich' },
271
- }],
272
- };
273
-
274
- vi.mocked(fetch)
275
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify(projectsResponse)))
276
- .mockResolvedValue(makeResponse(200, JSON.stringify({ users: [], versions: [], projects: [{ id: 1, categories: [] }], tags: [] })));
277
-
278
- await mockServer.callTool('sync_metadata', {});
279
-
280
- const writeCall = vi.mocked(writeFile).mock.calls.find(call => String(call[0]).includes('metadata.json'));
281
- const written = JSON.parse(writeCall![1] as string) as { data: { projects: Array<Record<string, unknown>> } };
282
- const project = written.data.projects[0]!;
283
- expect((project['status'] as Record<string, unknown>)['label']).toBe('Entwicklung');
284
- expect((project['view_state'] as Record<string, unknown>)['label']).toBe('Öffentlich');
285
- });
286
-
287
- it('fetches versions with obsolete=1 and inherit=1', async () => {
288
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
289
- vi.mocked(mkdir).mockResolvedValue(undefined);
290
- vi.mocked(writeFile).mockResolvedValue(undefined);
291
-
292
- const projectsResponse = { projects: [{ id: 1, name: 'Test Project' }] };
293
-
294
- vi.mocked(fetch)
295
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify(projectsResponse)))
296
- .mockResolvedValue(makeResponse(200, JSON.stringify({ users: [], versions: [], projects: [{ id: 1, categories: [] }], tags: [] })));
297
-
298
- await mockServer.callTool('sync_metadata', {});
299
-
300
- // The versions call must include obsolete=1 and inherit=1
301
- const versionCall = vi.mocked(fetch).mock.calls.find(
302
- call => String(call[0]).includes('/versions')
303
- );
304
- expect(versionCall).toBeDefined();
305
- const url = new URL(versionCall![0] as string);
306
- expect(url.searchParams.get('obsolete')).toBe('1');
307
- expect(url.searchParams.get('inherit')).toBe('1');
308
- });
309
-
310
- it('stores categories from GET /projects/{id} response (not from /categories sub-path)', async () => {
311
- // Regression: sync_metadata called projects/{id}/categories which returned []
312
- // on this MantisBT installation. The correct source is projects/{id}.projects[0].categories,
313
- // identical to what get_project_categories uses.
314
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
315
- vi.mocked(mkdir).mockResolvedValue(undefined);
316
- vi.mocked(writeFile).mockResolvedValue(undefined);
317
-
318
- const projectsResponse = { projects: [{ id: 1, name: 'Test Project' }] };
319
- const usersResponse = { users: [] };
320
- const versionsResponse = { versions: [] };
321
- // GET /projects/1 returns categories embedded in the project object
322
- const projectDetailResponse = {
323
- projects: [{ id: 1, name: 'Test Project', categories: [{ id: 10, name: 'General' }, { id: 11, name: 'Bug' }] }],
324
- };
325
- const tagsResponse = { tags: [] };
326
-
327
- vi.mocked(fetch)
328
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify(projectsResponse))) // GET /projects
329
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify(usersResponse))) // GET /projects/1/users
330
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify(versionsResponse))) // GET /projects/1/versions
331
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify(projectDetailResponse))) // GET /projects/1
332
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify(tagsResponse))); // GET /tags
333
-
334
- const result = await mockServer.callTool('sync_metadata', {});
335
-
336
- expect(result.isError).toBeUndefined();
337
- // Summary must report 2 categories for the project
338
- expect(result.content[0]!.text).toContain('2 categories');
339
-
340
- // Written cache must contain categories in byProject
341
- const writeCall = vi.mocked(writeFile).mock.calls.find(
342
- call => String(call[0]).includes('metadata.json')
343
- );
344
- const written = JSON.parse(writeCall![1] as string) as {
345
- data: { byProject: Record<string, { categories: unknown[] }> };
346
- };
347
- expect(written.data.byProject[1]!.categories).toHaveLength(2);
348
- });
349
-
350
- it('stores empty tags array when tags endpoint fails (#7860)', async () => {
351
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
352
- vi.mocked(mkdir).mockResolvedValue(undefined);
353
- vi.mocked(writeFile).mockResolvedValue(undefined);
354
-
355
- const projectsResponse = { projects: [] };
356
- // Tags endpoint returns 500 — must degrade gracefully
357
- vi.mocked(fetch)
358
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify(projectsResponse)))
359
- .mockResolvedValueOnce(makeResponse(500, 'Internal Server Error'));
360
-
361
- const result = await mockServer.callTool('sync_metadata', {});
362
-
363
- expect(result.isError).toBeUndefined();
364
- expect(result.content[0]!.text).toContain('Global tags: 0');
365
- });
366
- });
367
-
368
- // ---------------------------------------------------------------------------
369
- // get_issue_fields
370
- // ---------------------------------------------------------------------------
371
-
372
- describe('get_issue_fields', () => {
373
- it('is registered', () => {
374
- expect(mockServer.hasToolRegistered('get_issue_fields')).toBe(true);
375
- });
376
-
377
- it('returns cached fields when cache is valid', async () => {
378
- const cachedFields = ['id', 'summary', 'status', 'priority', 'attachments'];
379
- const cacheFile = JSON.stringify({ timestamp: Date.now(), fields: cachedFields });
380
-
381
- // loadIssueFields reads from issue_fields.json
382
- vi.mocked(readFile).mockImplementation(async (path) => {
383
- if (String(path).includes('issue_fields.json')) {
384
- return cacheFile as any;
385
- }
386
- throw new Error('ENOENT');
387
- });
388
-
389
- const result = await mockServer.callTool('get_issue_fields', {});
390
-
391
- expect(result.isError).toBeUndefined();
392
- // fetch must NOT have been called — data came from cache
393
- expect(fetch).not.toHaveBeenCalled();
394
- const parsed = JSON.parse(result.content[0]!.text) as { fields: string[]; source: string };
395
- expect(parsed.source).toBe('cache');
396
- expect(parsed.fields).toEqual(cachedFields);
397
- });
398
-
399
- it('fetches sample issue and discovers fields', async () => {
400
- // Cache miss
401
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
402
- vi.mocked(mkdir).mockResolvedValue(undefined);
403
- vi.mocked(writeFile).mockResolvedValue(undefined);
404
-
405
- const sampleIssue = {
406
- id: 42,
407
- summary: 'Sample',
408
- status: { id: 10, name: 'new' },
409
- priority: { id: 30, name: 'normal' },
410
- reporter: { id: 1, name: 'user' },
411
- };
412
- const issuesResponse = { issues: [sampleIssue] };
413
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(issuesResponse)));
414
-
415
- const result = await mockServer.callTool('get_issue_fields', {});
416
-
417
- expect(result.isError).toBeUndefined();
418
- const parsed = JSON.parse(result.content[0]!.text) as { fields: string[]; source: string };
419
- expect(parsed.source).toBe('live');
420
- // Fields from sample issue
421
- expect(parsed.fields).toContain('id');
422
- expect(parsed.fields).toContain('summary');
423
- expect(parsed.fields).toContain('status');
424
- // Fields from EMPTY_STRIPPED_FIELDS that are always merged in
425
- expect(parsed.fields).toContain('attachments');
426
- expect(parsed.fields).toContain('notes');
427
- expect(parsed.fields).toContain('relationships');
428
- });
429
-
430
- it('falls back to static list when no issues available', async () => {
431
- // Cache miss
432
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
433
- vi.mocked(mkdir).mockResolvedValue(undefined);
434
- vi.mocked(writeFile).mockResolvedValue(undefined);
435
-
436
- const emptyIssuesResponse = { issues: [] };
437
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(emptyIssuesResponse)));
438
-
439
- const result = await mockServer.callTool('get_issue_fields', {});
440
-
441
- expect(result.isError).toBeUndefined();
442
- const parsed = JSON.parse(result.content[0]!.text) as { fields: string[]; source: string };
443
- expect(parsed.source).toBe('static');
444
- // Static list must contain common fields
445
- expect(parsed.fields).toContain('id');
446
- expect(parsed.fields).toContain('summary');
447
- expect(parsed.fields).toContain('status');
448
- expect(parsed.fields).toContain('attachments');
449
- expect(parsed.fields).toContain('notes');
450
- });
451
-
452
- it('caches discovered fields after fetching', async () => {
453
- // Cache miss
454
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
455
- vi.mocked(mkdir).mockResolvedValue(undefined);
456
- vi.mocked(writeFile).mockResolvedValue(undefined);
457
-
458
- const sampleIssue = { id: 1, summary: 'Test', status: { id: 10, name: 'new' } };
459
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ issues: [sampleIssue] })));
460
-
461
- await mockServer.callTool('get_issue_fields', {});
462
-
463
- // writeFile must have been called with issue_fields.json path
464
- expect(writeFile).toHaveBeenCalled();
465
- const writtenPath = vi.mocked(writeFile).mock.calls.find(
466
- call => String(call[0]).includes('issue_fields.json')
467
- );
468
- expect(writtenPath).toBeDefined();
469
-
470
- // Written content must be valid JSON with a fields array
471
- const writtenContent = writtenPath![1] as string;
472
- const parsed = JSON.parse(writtenContent) as { timestamp: number; fields: string[] };
473
- expect(Array.isArray(parsed.fields)).toBe(true);
474
- expect(parsed.fields.length).toBeGreaterThan(0);
475
- expect(typeof parsed.timestamp).toBe('number');
476
- });
477
- });
478
-
479
- // ---------------------------------------------------------------------------
480
- // get_issue_fields – recorded fixtures
481
- // ---------------------------------------------------------------------------
482
-
483
- describe('get_issue_fields – recorded fixtures', () => {
484
- it.skipIf(!recordedSampleFixture)('discovers fields from recorded sample issue', async () => {
485
- // Cache miss — force live discovery
486
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
487
- vi.mocked(mkdir).mockResolvedValue(undefined);
488
- vi.mocked(writeFile).mockResolvedValue(undefined);
489
-
490
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(recordedSampleFixture!)));
491
-
492
- const result = await mockServer.callTool('get_issue_fields', {});
493
-
494
- expect(result.isError).toBeUndefined();
495
- const parsed = JSON.parse(result.content[0]!.text) as { fields: string[]; source: string };
496
- expect(parsed.source).toBe('live');
497
-
498
- // Every top-level key of the recorded sample issue must be in the discovered fields
499
- const sampleKeys = Object.keys(recordedSampleFixture!.issues[0]!);
500
- for (const key of sampleKeys) {
501
- expect(parsed.fields).toContain(key);
502
- }
503
-
504
- // EMPTY_STRIPPED_FIELDS must always be present even if absent from the sample
505
- expect(parsed.fields).toContain('attachments');
506
- expect(parsed.fields).toContain('notes');
507
- expect(parsed.fields).toContain('relationships');
508
- });
509
- });
@@ -1,101 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { MantisClient } from '../../src/client.js';
3
- import { registerMonitorTools } from '../../src/tools/monitors.js';
4
- import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
5
-
6
- // ---------------------------------------------------------------------------
7
- // Setup
8
- // ---------------------------------------------------------------------------
9
-
10
- let mockServer: MockMcpServer;
11
- let client: MantisClient;
12
-
13
- beforeEach(() => {
14
- mockServer = new MockMcpServer();
15
- client = new MantisClient('https://mantis.example.com', 'test-token');
16
- registerMonitorTools(mockServer as never, client);
17
- vi.stubGlobal('fetch', vi.fn());
18
- });
19
-
20
- afterEach(() => {
21
- vi.unstubAllGlobals();
22
- });
23
-
24
- // ---------------------------------------------------------------------------
25
- // add_monitor
26
- // ---------------------------------------------------------------------------
27
-
28
- describe('add_monitor', () => {
29
- it('is registered', () => {
30
- expect(mockServer.hasToolRegistered('add_monitor')).toBe(true);
31
- });
32
-
33
- it('posts to the correct endpoint', async () => {
34
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, ''));
35
-
36
- await mockServer.callTool('add_monitor', { issue_id: 42, username: 'jdoe' });
37
-
38
- const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
39
- expect(calledUrl).toContain('issues/42/monitors');
40
- });
41
-
42
- it('sends the username in the request body', async () => {
43
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, ''));
44
-
45
- await mockServer.callTool('add_monitor', { issue_id: 42, username: 'jdoe' });
46
-
47
- const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
48
- const body = JSON.parse(options.body as string) as { name: string };
49
- expect(body.name).toBe('jdoe');
50
- });
51
-
52
- it('returns isError on API error', async () => {
53
- vi.mocked(fetch).mockResolvedValue(makeResponse(400, JSON.stringify({ message: 'User not found' })));
54
-
55
- const result = await mockServer.callTool('add_monitor', { issue_id: 42, username: 'nobody' });
56
-
57
- expect(result.isError).toBe(true);
58
- expect(result.content[0]!.text).toContain('Error:');
59
- });
60
- });
61
-
62
- // ---------------------------------------------------------------------------
63
- // remove_monitor
64
- // ---------------------------------------------------------------------------
65
-
66
- describe('remove_monitor', () => {
67
- it('is registered', () => {
68
- expect(mockServer.hasToolRegistered('remove_monitor')).toBe(true);
69
- });
70
-
71
- it('sends DELETE to the correct endpoint', async () => {
72
- vi.mocked(fetch).mockResolvedValue(makeResponse(204, ''));
73
-
74
- await mockServer.callTool('remove_monitor', { issue_id: 42, username: 'jdoe' });
75
-
76
- const call = vi.mocked(fetch).mock.calls[0]!;
77
- const calledUrl = call[0] as string;
78
- const options = call[1] as RequestInit;
79
- expect(calledUrl).toContain('issues/42/monitors/jdoe');
80
- expect(options.method).toBe('DELETE');
81
- });
82
-
83
- it('returns { success: true } on 204', async () => {
84
- vi.mocked(fetch).mockResolvedValue(makeResponse(204, ''));
85
-
86
- const result = await mockServer.callTool('remove_monitor', { issue_id: 42, username: 'jdoe' });
87
-
88
- expect(result.isError).toBeUndefined();
89
- const parsed = JSON.parse(result.content[0]!.text) as { success: boolean };
90
- expect(parsed.success).toBe(true);
91
- });
92
-
93
- it('returns isError on API error', async () => {
94
- vi.mocked(fetch).mockResolvedValue(makeResponse(404, JSON.stringify({ message: 'Monitor not found' })));
95
-
96
- const result = await mockServer.callTool('remove_monitor', { issue_id: 42, username: 'nobody' });
97
-
98
- expect(result.isError).toBe(true);
99
- expect(result.content[0]!.text).toContain('Error:');
100
- });
101
- });