@dpesch/mantisbt-mcp-server 1.7.0 → 1.8.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.
@@ -2,7 +2,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { readFileSync, existsSync } from 'node:fs';
3
3
  import { join, dirname } from 'node:path';
4
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 } from 'node:fs/promises';
5
10
  import { MantisClient } from '../../src/client.js';
11
+ import { MetadataCache, type CachedMetadata } from '../../src/cache.js';
12
+ import type { MantisUser } from '../../src/types.js';
6
13
  import { registerProjectTools } from '../../src/tools/projects.js';
7
14
  import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
8
15
 
@@ -28,11 +35,14 @@ const firstProjectId = listProjectsFixture.projects[0]?.id ?? 1;
28
35
 
29
36
  let mockServer: MockMcpServer;
30
37
  let client: MantisClient;
38
+ let cache: MetadataCache;
31
39
 
32
40
  beforeEach(() => {
41
+ vi.resetAllMocks();
33
42
  mockServer = new MockMcpServer();
34
43
  client = new MantisClient('https://mantis.example.com', 'test-token');
35
- registerProjectTools(mockServer as never, client);
44
+ cache = new MetadataCache('/tmp/test-cache-projects', 3600);
45
+ registerProjectTools(mockServer as never, client, cache);
36
46
  vi.stubGlobal('fetch', vi.fn());
37
47
  });
38
48
 
@@ -40,6 +50,20 @@ afterEach(() => {
40
50
  vi.unstubAllGlobals();
41
51
  });
42
52
 
53
+ // ---------------------------------------------------------------------------
54
+ // Helpers
55
+ // ---------------------------------------------------------------------------
56
+
57
+ async function seedCache(users: MantisUser[]): Promise<void> {
58
+ const data: CachedMetadata = {
59
+ timestamp: Date.now(),
60
+ projects: [{ id: 7, name: 'TestProject' }],
61
+ byProject: { 7: { users, versions: [], categories: [] } },
62
+ tags: [],
63
+ };
64
+ vi.mocked(readFile).mockResolvedValue(JSON.stringify({ timestamp: Date.now(), data }) as any);
65
+ }
66
+
43
67
  // ---------------------------------------------------------------------------
44
68
  // list_projects
45
69
  // ---------------------------------------------------------------------------
@@ -70,6 +94,41 @@ describe('list_projects', () => {
70
94
  expect(result.isError).toBe(true);
71
95
  expect(result.content[0]!.text).toContain('Error:');
72
96
  });
97
+
98
+ it('strips custom_fields from projects', async () => {
99
+ vi.mocked(fetch).mockResolvedValueOnce(
100
+ makeResponse(200, JSON.stringify({
101
+ projects: [{ id: 1, name: 'Alpha', custom_fields: [{ field: { id: 9, name: 'cf' }, value: 'x' }] }],
102
+ }))
103
+ );
104
+ const result = await mockServer.callTool('list_projects', {});
105
+ const parsed = JSON.parse(result.content[0]!.text) as Array<{ custom_fields?: unknown }>;
106
+ expect(parsed[0]!.custom_fields).toBeUndefined();
107
+ });
108
+
109
+ it('preserves status, enabled, view_state after normalization', async () => {
110
+ vi.mocked(fetch).mockResolvedValueOnce(
111
+ makeResponse(200, JSON.stringify({
112
+ projects: [{ id: 1, name: 'Alpha', enabled: true, status: { id: 10, name: 'development', label: 'Dev' }, view_state: { id: 10, name: 'public', label: 'Public' } }],
113
+ }))
114
+ );
115
+ const result = await mockServer.callTool('list_projects', {});
116
+ const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
117
+ expect(parsed[0]!['enabled']).toBe(true);
118
+ expect((parsed[0]!['status'] as Record<string, unknown>)['id']).toBe(10);
119
+ expect((parsed[0]!['view_state'] as Record<string, unknown>)['id']).toBe(10);
120
+ });
121
+
122
+ it('normalizes subprojects recursively', async () => {
123
+ vi.mocked(fetch).mockResolvedValueOnce(
124
+ makeResponse(200, JSON.stringify({
125
+ projects: [{ id: 1, name: 'Parent', subprojects: [{ id: 2, name: 'Child', custom_fields: [{ field: { id: 9 }, value: 'x' }] }] }],
126
+ }))
127
+ );
128
+ const result = await mockServer.callTool('list_projects', {});
129
+ const parsed = JSON.parse(result.content[0]!.text) as Array<{ subprojects?: Array<{ custom_fields?: unknown }> }>;
130
+ expect(parsed[0]!.subprojects![0]!.custom_fields).toBeUndefined();
131
+ });
73
132
  });
74
133
 
75
134
  // ---------------------------------------------------------------------------
@@ -184,3 +243,96 @@ describe('get_project_categories', () => {
184
243
  expect(secondCategory?.name).toBe('Backend');
185
244
  });
186
245
  });
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // find_project_member
249
+ // ---------------------------------------------------------------------------
250
+
251
+ describe('find_project_member', () => {
252
+ it('is registered', () => {
253
+ expect(mockServer.hasToolRegistered('find_project_member')).toBe(true);
254
+ });
255
+
256
+ it('returns cached users without fetch', async () => {
257
+ const users: MantisUser[] = [{ id: 1, name: 'alice' }, { id: 2, name: 'bob' }];
258
+ await seedCache(users);
259
+ const result = await mockServer.callTool('find_project_member', { project_id: 7 });
260
+ const parsed = JSON.parse(result.content[0]!.text) as MantisUser[];
261
+ expect(parsed).toHaveLength(2);
262
+ expect(vi.mocked(fetch)).not.toHaveBeenCalled();
263
+ });
264
+
265
+ it('filters case-insensitively by name', async () => {
266
+ await seedCache([{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]);
267
+ const result = await mockServer.callTool('find_project_member', { project_id: 7, query: 'alice' });
268
+ const parsed = JSON.parse(result.content[0]!.text) as MantisUser[];
269
+ expect(parsed).toHaveLength(1);
270
+ expect(parsed[0]!.name).toBe('Alice');
271
+ });
272
+
273
+ it('filters by real_name', async () => {
274
+ await seedCache([{ id: 1, name: 'a', real_name: 'Alice Smith' }, { id: 2, name: 'b', real_name: 'Bob Jones' }]);
275
+ const result = await mockServer.callTool('find_project_member', { project_id: 7, query: 'smith' });
276
+ const parsed = JSON.parse(result.content[0]!.text) as MantisUser[];
277
+ expect(parsed).toHaveLength(1);
278
+ expect(parsed[0]!.real_name).toBe('Alice Smith');
279
+ });
280
+
281
+ it('filters by email', async () => {
282
+ await seedCache([{ id: 1, name: 'a', email: 'alice@example.com' }, { id: 2, name: 'b', email: 'bob@example.com' }]);
283
+ const result = await mockServer.callTool('find_project_member', { project_id: 7, query: 'alice@' });
284
+ const parsed = JSON.parse(result.content[0]!.text) as MantisUser[];
285
+ expect(parsed).toHaveLength(1);
286
+ });
287
+
288
+ it('applies default limit of 10', async () => {
289
+ const users: MantisUser[] = Array.from({ length: 15 }, (_, i) => ({ id: i + 1, name: `user${i + 1}` }));
290
+ await seedCache(users);
291
+ const result = await mockServer.callTool('find_project_member', { project_id: 7 });
292
+ const parsed = JSON.parse(result.content[0]!.text) as MantisUser[];
293
+ expect(parsed).toHaveLength(10);
294
+ });
295
+
296
+ it('respects explicit limit', async () => {
297
+ const users: MantisUser[] = Array.from({ length: 15 }, (_, i) => ({ id: i + 1, name: `user${i + 1}` }));
298
+ await seedCache(users);
299
+ const result = await mockServer.callTool('find_project_member', { project_id: 7, limit: 5 });
300
+ const parsed = JSON.parse(result.content[0]!.text) as MantisUser[];
301
+ expect(parsed).toHaveLength(5);
302
+ });
303
+
304
+ it('returns empty array when no match', async () => {
305
+ await seedCache([{ id: 1, name: 'alice' }]);
306
+ const result = await mockServer.callTool('find_project_member', { project_id: 7, query: 'zzznomatch' });
307
+ const parsed = JSON.parse(result.content[0]!.text) as MantisUser[];
308
+ expect(parsed).toHaveLength(0);
309
+ });
310
+
311
+ it('falls back to live API when cache is cold', async () => {
312
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
313
+ vi.mocked(fetch).mockResolvedValueOnce(
314
+ makeResponse(200, JSON.stringify({ users: [{ id: 1, name: 'alice' }] }))
315
+ );
316
+ const result = await mockServer.callTool('find_project_member', { project_id: 7 });
317
+ const parsed = JSON.parse(result.content[0]!.text) as MantisUser[];
318
+ expect(parsed).toHaveLength(1);
319
+ expect(vi.mocked(fetch)).toHaveBeenCalledOnce();
320
+ });
321
+
322
+ it('returns isError on API failure', async () => {
323
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
324
+ vi.mocked(fetch).mockResolvedValueOnce(makeResponse(500, JSON.stringify({ message: 'Internal Server Error' })));
325
+ const result = await mockServer.callTool('find_project_member', { project_id: 7 });
326
+ expect(result.isError).toBe(true);
327
+ });
328
+
329
+ it('rejects invalid project_id', async () => {
330
+ const result = await mockServer.callTool('find_project_member', { project_id: -1 }, { validate: true });
331
+ expect(result.isError).toBe(true);
332
+ });
333
+
334
+ it('rejects limit < 1', async () => {
335
+ const result = await mockServer.callTool('find_project_member', { project_id: 7, limit: 0 }, { validate: true });
336
+ expect(result.isError).toBe(true);
337
+ });
338
+ });