@dpesch/mantisbt-mcp-server 1.10.2 → 1.10.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.de.md +2 -1
- package/README.md +2 -1
- package/dist/client.js +0 -15
- package/dist/tools/files.js +14 -15
- package/docs/cookbook.de.md +1 -1
- package/docs/cookbook.md +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/.gitea/PULL_REQUEST_TEMPLATE.md +0 -17
- package/.gitea/workflows/ci.yml +0 -95
- package/.github/workflows/ci.yml +0 -32
- package/scripts/hooks/pre-push.mjs +0 -220
- package/scripts/init.mjs +0 -95
- package/scripts/record-fixtures.ts +0 -160
- package/tests/cache.test.ts +0 -265
- package/tests/client.test.ts +0 -372
- package/tests/config.test.ts +0 -263
- package/tests/fixtures/get_current_user.json +0 -40
- package/tests/fixtures/get_issue.json +0 -133
- package/tests/fixtures/get_issue_enums.json +0 -67
- package/tests/fixtures/get_issue_fields_sample.json +0 -76
- package/tests/fixtures/get_project_categories.json +0 -157
- package/tests/fixtures/get_project_versions.json +0 -3
- package/tests/fixtures/get_project_versions_with_data.json +0 -52
- package/tests/fixtures/list_issues.json +0 -95
- package/tests/fixtures/list_projects.json +0 -65
- package/tests/helpers/mock-server.ts +0 -166
- package/tests/helpers/search-mocks.ts +0 -84
- package/tests/prompts/prompts.test.ts +0 -242
- package/tests/resources/resources.test.ts +0 -309
- package/tests/search/embedder.test.ts +0 -81
- package/tests/search/highlight.test.ts +0 -129
- package/tests/search/store.test.ts +0 -193
- package/tests/search/sync.test.ts +0 -249
- package/tests/search/tools.test.ts +0 -661
- package/tests/tools/config.test.ts +0 -212
- package/tests/tools/files.test.ts +0 -344
- package/tests/tools/issues.test.ts +0 -1180
- package/tests/tools/metadata.test.ts +0 -509
- package/tests/tools/monitors.test.ts +0 -101
- package/tests/tools/projects.test.ts +0 -338
- package/tests/tools/relationships.test.ts +0 -177
- package/tests/tools/string-coercion.test.ts +0 -317
- package/tests/tools/users.test.ts +0 -62
- package/tests/utils/date-filter.test.ts +0 -169
- package/tests/version-hint.test.ts +0 -230
- package/tsconfig.build.json +0 -8
- 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
|
-
});
|