@dpesch/mantisbt-mcp-server 1.4.0 → 1.5.1
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/.env.local +3 -0
- package/CHANGELOG.md +18 -0
- package/README.de.md +5 -4
- package/README.md +5 -4
- package/dist/config.js +2 -0
- package/dist/constants.js +77 -0
- package/dist/index.js +1 -1
- package/dist/search/embedder.js +10 -3
- package/dist/search/index.js +1 -1
- package/dist/search/tools.js +27 -2
- package/dist/tools/config.js +16 -2
- package/dist/tools/issues.js +46 -6
- package/dist/tools/relationships.js +27 -11
- package/package.json +1 -1
- package/tests/fixtures/recorded/get_current_user.json +108 -0
- package/tests/fixtures/recorded/get_issue.json +138 -0
- package/tests/fixtures/recorded/get_issue_enums.json +67 -0
- package/tests/fixtures/recorded/get_issue_fields_sample.json +138 -0
- package/tests/fixtures/recorded/get_project_categories.json +241 -0
- package/tests/fixtures/recorded/get_project_versions.json +28 -0
- package/tests/fixtures/recorded/list_issues.json +463 -0
- package/tests/fixtures/recorded/list_projects.json +10641 -0
- package/tests/search/embedder.test.ts +81 -0
- package/tests/search/tools.test.ts +117 -0
- package/tests/tools/config.test.ts +71 -2
- package/tests/tools/issues.test.ts +156 -1
- package/tests/tools/relationships.test.ts +75 -0
- package/tests/tools/string-coercion.test.ts +3 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { Embedder } from '../../src/search/embedder.js';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Mock @huggingface/transformers (dynamic import)
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const mockPipelineFn = vi.fn(async (texts: string | string[]) => {
|
|
9
|
+
if (Array.isArray(texts)) {
|
|
10
|
+
return texts.map(() => ({ data: new Float32Array(4).fill(0.1), dims: [1, 4] }));
|
|
11
|
+
}
|
|
12
|
+
return { data: new Float32Array(4).fill(0.1), dims: [4] };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const mockPipelineFactory = vi.fn(async (_task: string, _model: string, _opts?: unknown) => mockPipelineFn);
|
|
16
|
+
|
|
17
|
+
vi.mock('@huggingface/transformers', () => ({
|
|
18
|
+
pipeline: mockPipelineFactory,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Thread configuration
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
describe('Embedder – thread configuration', () => {
|
|
30
|
+
it('passes intra_op_num_threads=1 by default', async () => {
|
|
31
|
+
const embedder = new Embedder('test-model');
|
|
32
|
+
await embedder.embed('hello');
|
|
33
|
+
|
|
34
|
+
expect(mockPipelineFactory).toHaveBeenCalledWith(
|
|
35
|
+
'feature-extraction',
|
|
36
|
+
'test-model',
|
|
37
|
+
expect.objectContaining({
|
|
38
|
+
session_options: { intra_op_num_threads: 1, inter_op_num_threads: 1 },
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('passes configured numThreads to intra_op_num_threads; inter stays 1', async () => {
|
|
44
|
+
const embedder = new Embedder('test-model', 4);
|
|
45
|
+
await embedder.embed('hello');
|
|
46
|
+
|
|
47
|
+
expect(mockPipelineFactory).toHaveBeenCalledWith(
|
|
48
|
+
'feature-extraction',
|
|
49
|
+
'test-model',
|
|
50
|
+
expect.objectContaining({
|
|
51
|
+
session_options: { intra_op_num_threads: 4, inter_op_num_threads: 1 },
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('loads the pipeline only once (lazy singleton)', async () => {
|
|
57
|
+
const embedder = new Embedder('test-model', 1);
|
|
58
|
+
await embedder.embed('first');
|
|
59
|
+
await embedder.embed('second');
|
|
60
|
+
|
|
61
|
+
expect(mockPipelineFactory).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Embedder default — numThreads omitted
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
describe('Embedder – numThreads default', () => {
|
|
70
|
+
it('uses intra_op_num_threads=1 when numThreads is not passed', async () => {
|
|
71
|
+
const embedder = new Embedder('m');
|
|
72
|
+
await embedder.embed('x');
|
|
73
|
+
expect(mockPipelineFactory).toHaveBeenCalledWith(
|
|
74
|
+
'feature-extraction',
|
|
75
|
+
'm',
|
|
76
|
+
expect.objectContaining({
|
|
77
|
+
session_options: expect.objectContaining({ intra_op_num_threads: 1 }),
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -159,6 +159,123 @@ describe('rebuild_search_index – full: false', () => {
|
|
|
159
159
|
});
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// search_issues – select parameter
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
describe('search_issues – select parameter', () => {
|
|
167
|
+
it('returns plain {id, score} array when select is not provided', async () => {
|
|
168
|
+
const store = makeMockStore({ itemCount: 2 });
|
|
169
|
+
registerSearchTools(mockServer as never, client, store, embedder);
|
|
170
|
+
|
|
171
|
+
const result = await mockServer.callTool('search_issues', { query: 'test', top_n: 2 });
|
|
172
|
+
|
|
173
|
+
expect(result.isError).toBeUndefined();
|
|
174
|
+
const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number; score: number }>;
|
|
175
|
+
expect(parsed[0]).toEqual(expect.objectContaining({ id: expect.any(Number), score: expect.any(Number) }));
|
|
176
|
+
expect(Object.keys(parsed[0]!)).toEqual(['id', 'score']);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('fetches issues and projects requested fields when select is provided', async () => {
|
|
180
|
+
const store = makeMockStore({ itemCount: 2 });
|
|
181
|
+
registerSearchTools(mockServer as never, client, store, embedder);
|
|
182
|
+
|
|
183
|
+
vi.mocked(fetch)
|
|
184
|
+
.mockResolvedValueOnce(
|
|
185
|
+
makeResponse(200, JSON.stringify({ issues: [{ id: 1, summary: 'Login bug', status: { id: 10, name: 'new' }, priority: { id: 30, name: 'normal' } }] }))
|
|
186
|
+
)
|
|
187
|
+
.mockResolvedValueOnce(
|
|
188
|
+
makeResponse(200, JSON.stringify({ issues: [{ id: 2, summary: 'Crash on save', status: { id: 50, name: 'assigned' }, priority: { id: 40, name: 'high' } }] }))
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const result = await mockServer.callTool('search_issues', { query: 'test', top_n: 2, select: 'summary,status' });
|
|
192
|
+
|
|
193
|
+
expect(result.isError).toBeUndefined();
|
|
194
|
+
const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
|
|
195
|
+
expect(parsed).toHaveLength(2);
|
|
196
|
+
// id and score always present
|
|
197
|
+
expect(parsed[0]).toHaveProperty('id');
|
|
198
|
+
expect(parsed[0]).toHaveProperty('score');
|
|
199
|
+
// requested fields present
|
|
200
|
+
expect(parsed[0]).toHaveProperty('summary', 'Login bug');
|
|
201
|
+
expect(parsed[0]).toHaveProperty('status');
|
|
202
|
+
// non-requested field absent
|
|
203
|
+
expect(parsed[0]).not.toHaveProperty('priority');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('id and score are always included even when not listed in select', async () => {
|
|
207
|
+
const store = makeMockStore({ itemCount: 1 });
|
|
208
|
+
registerSearchTools(mockServer as never, client, store, embedder);
|
|
209
|
+
|
|
210
|
+
vi.mocked(fetch).mockResolvedValueOnce(
|
|
211
|
+
makeResponse(200, JSON.stringify({ issues: [{ id: 1, summary: 'Test issue' }] }))
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const result = await mockServer.callTool('search_issues', { query: 'test', top_n: 1, select: 'summary' });
|
|
215
|
+
|
|
216
|
+
const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
|
|
217
|
+
expect(parsed[0]).toHaveProperty('id', 1);
|
|
218
|
+
expect(parsed[0]).toHaveProperty('score');
|
|
219
|
+
expect(parsed[0]).toHaveProperty('summary', 'Test issue');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('falls back to {id, score} when issue fetch fails', async () => {
|
|
223
|
+
const store = makeMockStore({ itemCount: 2 });
|
|
224
|
+
registerSearchTools(mockServer as never, client, store, embedder);
|
|
225
|
+
|
|
226
|
+
vi.mocked(fetch)
|
|
227
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issues: [{ id: 1, summary: 'OK issue' }] })))
|
|
228
|
+
.mockResolvedValueOnce(makeResponse(500, 'Internal Server Error'));
|
|
229
|
+
|
|
230
|
+
const result = await mockServer.callTool('search_issues', { query: 'test', top_n: 2, select: 'summary' });
|
|
231
|
+
|
|
232
|
+
expect(result.isError).toBeUndefined();
|
|
233
|
+
const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
|
|
234
|
+
expect(parsed).toHaveLength(2);
|
|
235
|
+
// First item enriched
|
|
236
|
+
expect(parsed[0]).toHaveProperty('summary');
|
|
237
|
+
// Second item fallback — only id and score
|
|
238
|
+
expect(Object.keys(parsed[1]!).sort()).toEqual(['id', 'score']);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('omits non-existent fields silently', async () => {
|
|
242
|
+
const store = makeMockStore({ itemCount: 1 });
|
|
243
|
+
registerSearchTools(mockServer as never, client, store, embedder);
|
|
244
|
+
|
|
245
|
+
vi.mocked(fetch).mockResolvedValueOnce(
|
|
246
|
+
makeResponse(200, JSON.stringify({ issues: [{ id: 1, summary: 'Test' }] }))
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const result = await mockServer.callTool('search_issues', { query: 'test', top_n: 1, select: 'summary,nonexistent_field' });
|
|
250
|
+
|
|
251
|
+
const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
|
|
252
|
+
expect(parsed[0]).toHaveProperty('summary', 'Test');
|
|
253
|
+
expect(parsed[0]).not.toHaveProperty('nonexistent_field');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('makes one API call per result when select is provided', async () => {
|
|
257
|
+
const store = makeMockStore({ itemCount: 3 });
|
|
258
|
+
registerSearchTools(mockServer as never, client, store, embedder);
|
|
259
|
+
|
|
260
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
261
|
+
makeResponse(200, JSON.stringify({ issues: [{ id: 1, summary: 'Issue' }] }))
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
await mockServer.callTool('search_issues', { query: 'test', top_n: 3, select: 'summary' });
|
|
265
|
+
|
|
266
|
+
expect(fetch).toHaveBeenCalledTimes(3);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('makes no API calls when select is not provided', async () => {
|
|
270
|
+
const store = makeMockStore({ itemCount: 3 });
|
|
271
|
+
registerSearchTools(mockServer as never, client, store, embedder);
|
|
272
|
+
|
|
273
|
+
await mockServer.callTool('search_issues', { query: 'test', top_n: 3 });
|
|
274
|
+
|
|
275
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
162
279
|
// ---------------------------------------------------------------------------
|
|
163
280
|
// get_search_index_status – registration
|
|
164
281
|
// ---------------------------------------------------------------------------
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
|
|
3
|
-
type
|
|
3
|
+
type EnumEntry = { id: number; name: string; label?: string; canonical_name?: string };
|
|
4
|
+
type EnumResult = Record<string, Array<EnumEntry>>;
|
|
4
5
|
import { readFileSync } from 'node:fs';
|
|
5
6
|
import { join, dirname } from 'node:path';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
@@ -100,8 +101,9 @@ describe('get_issue_enums', () => {
|
|
|
100
101
|
|
|
101
102
|
it('label wird weggelassen wenn er identisch mit name ist', () => {
|
|
102
103
|
// severity: name === label (z.B. "Feature-Wunsch" === "Feature-Wunsch") → kein label-Feld
|
|
104
|
+
// canonical_name is present because "Feature-Wunsch" differs from English "feature"
|
|
103
105
|
const severityFirst = parsed.severity.find(e => e.id === 10)!;
|
|
104
|
-
expect(severityFirst).
|
|
106
|
+
expect(severityFirst).toMatchObject({ id: 10, name: 'Feature-Wunsch', canonical_name: 'feature' });
|
|
105
107
|
expect(severityFirst).not.toHaveProperty('label');
|
|
106
108
|
|
|
107
109
|
// priority: name="normal", label="normal" → kein label-Feld
|
|
@@ -132,6 +134,73 @@ describe('get_issue_enums', () => {
|
|
|
132
134
|
expect(parsed.reproducibility).toContainEqual({ id: 70, name: 'have not tried' });
|
|
133
135
|
});
|
|
134
136
|
|
|
137
|
+
describe('canonical_name für lokalisierte Installationen', () => {
|
|
138
|
+
it('fügt canonical_name hinzu wenn name von englischem Standardwert abweicht', async () => {
|
|
139
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(enumFixture)));
|
|
140
|
+
const result = await mockServer.callTool('get_issue_enums', {});
|
|
141
|
+
const parsed = JSON.parse(result.content[0]!.text) as EnumResult;
|
|
142
|
+
|
|
143
|
+
// severity: names are German in fixture → canonical_name must be present
|
|
144
|
+
const minor = parsed.severity.find(e => e.id === 50)!;
|
|
145
|
+
expect(minor.name).toBe('kleinerer Fehler');
|
|
146
|
+
expect(minor.canonical_name).toBe('minor');
|
|
147
|
+
|
|
148
|
+
const block = parsed.severity.find(e => e.id === 80)!;
|
|
149
|
+
expect(block.name).toBe('Blocker');
|
|
150
|
+
expect(block.canonical_name).toBe('block');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('lässt canonical_name weg wenn name bereits englischer Standardwert ist', async () => {
|
|
154
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(enumFixture)));
|
|
155
|
+
const result = await mockServer.callTool('get_issue_enums', {});
|
|
156
|
+
const parsed = JSON.parse(result.content[0]!.text) as EnumResult;
|
|
157
|
+
|
|
158
|
+
// status: names are already English → no canonical_name
|
|
159
|
+
const statusNew = parsed.status.find(e => e.id === 10)!;
|
|
160
|
+
expect(statusNew.name).toBe('new');
|
|
161
|
+
expect(statusNew).not.toHaveProperty('canonical_name');
|
|
162
|
+
|
|
163
|
+
// priority: "normal" is already the canonical name
|
|
164
|
+
const priorityNormal = parsed.priority.find(e => e.id === 30)!;
|
|
165
|
+
expect(priorityNormal.name).toBe('normal');
|
|
166
|
+
expect(priorityNormal).not.toHaveProperty('canonical_name');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('lässt canonical_name weg für unbekannte/benutzerdefinierte IDs', async () => {
|
|
170
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(enumFixture)));
|
|
171
|
+
const result = await mockServer.callTool('get_issue_enums', {});
|
|
172
|
+
const parsed = JSON.parse(result.content[0]!.text) as EnumResult;
|
|
173
|
+
|
|
174
|
+
// severity ID 200 ("Technische Schuld") is a custom entry with no canonical mapping
|
|
175
|
+
const custom = parsed.severity.find(e => e.id === 200)!;
|
|
176
|
+
expect(custom).toBeDefined();
|
|
177
|
+
expect(custom).not.toHaveProperty('canonical_name');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('fügt canonical_name auch im String-Format (Legacy) hinzu', async () => {
|
|
181
|
+
// Override: use German names in legacy string format
|
|
182
|
+
const germanStringFixture = {
|
|
183
|
+
configs: [
|
|
184
|
+
{ option: 'severity_enum_string', value: '50:kleinerer Fehler,80:Blocker' },
|
|
185
|
+
{ option: 'status_enum_string', value: '10:new,80:resolved' },
|
|
186
|
+
{ option: 'priority_enum_string', value: '30:normal' },
|
|
187
|
+
{ option: 'resolution_enum_string', value: '20:fixed' },
|
|
188
|
+
{ option: 'reproducibility_enum_string', value: '10:always' },
|
|
189
|
+
],
|
|
190
|
+
};
|
|
191
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(germanStringFixture)));
|
|
192
|
+
const result = await mockServer.callTool('get_issue_enums', {});
|
|
193
|
+
const parsed = JSON.parse(result.content[0]!.text) as EnumResult;
|
|
194
|
+
|
|
195
|
+
const minor = parsed.severity.find(e => e.id === 50)!;
|
|
196
|
+
expect(minor.canonical_name).toBe('minor');
|
|
197
|
+
|
|
198
|
+
// status "new" is already canonical — no canonical_name
|
|
199
|
+
const statusNew = parsed.status.find(e => e.id === 10)!;
|
|
200
|
+
expect(statusNew).not.toHaveProperty('canonical_name');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
135
204
|
it('gibt isError: true bei API-Fehler zurück', async () => {
|
|
136
205
|
vi.mocked(fetch).mockResolvedValue(makeResponse(403, JSON.stringify({ message: 'Access denied' })));
|
|
137
206
|
|
|
@@ -4,9 +4,21 @@ 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 { MetadataCache } from '../../src/cache.js';
|
|
7
8
|
import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
|
|
8
9
|
import { MANTIS_RESOLVED_STATUS_ID } from '../../src/constants.js';
|
|
9
10
|
|
|
11
|
+
function makeStubCache(projectUsers?: Array<{ id: number; name: string; real_name?: string }>): MetadataCache {
|
|
12
|
+
return {
|
|
13
|
+
loadIfValid: vi.fn(async () => projectUsers ? {
|
|
14
|
+
timestamp: Date.now(),
|
|
15
|
+
projects: [],
|
|
16
|
+
tags: [],
|
|
17
|
+
byProject: { 1: { users: projectUsers, versions: [], categories: [] } },
|
|
18
|
+
} : null),
|
|
19
|
+
} as unknown as MetadataCache;
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
const __filename = fileURLToPath(import.meta.url);
|
|
11
23
|
const __dirname = dirname(__filename);
|
|
12
24
|
const fixturesDir = join(__dirname, '..', 'fixtures');
|
|
@@ -41,7 +53,7 @@ let client: MantisClient;
|
|
|
41
53
|
beforeEach(() => {
|
|
42
54
|
mockServer = new MockMcpServer();
|
|
43
55
|
client = new MantisClient('https://mantis.example.com', 'test-token');
|
|
44
|
-
registerIssueTools(mockServer as never, client);
|
|
56
|
+
registerIssueTools(mockServer as never, client, makeStubCache());
|
|
45
57
|
vi.stubGlobal('fetch', vi.fn());
|
|
46
58
|
});
|
|
47
59
|
|
|
@@ -117,6 +129,57 @@ describe('create_issue', () => {
|
|
|
117
129
|
expect(body.severity).toEqual({ name: 'minor' });
|
|
118
130
|
});
|
|
119
131
|
|
|
132
|
+
it('returns full issue object when API responds with complete issue', async () => {
|
|
133
|
+
const fullIssue = { id: 100, summary: 'New issue', status: { id: 10, name: 'new' }, severity: { id: 50, name: 'minor' } };
|
|
134
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
135
|
+
makeResponse(201, JSON.stringify({ issue: fullIssue }))
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const result = await mockServer.callTool('create_issue', {
|
|
139
|
+
summary: 'New issue', project_id: 1, category: 'General',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(result.isError).toBeUndefined();
|
|
143
|
+
const parsed = JSON.parse(result.content[0]!.text) as typeof fullIssue;
|
|
144
|
+
expect(parsed.id).toBe(100);
|
|
145
|
+
expect(parsed.summary).toBe('New issue');
|
|
146
|
+
// Only one API call — no extra GET needed
|
|
147
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('fetches full issue via GET when API returns only an id (older MantisBT)', async () => {
|
|
151
|
+
const fullIssue = { id: 101, summary: 'Created issue', status: { id: 10, name: 'new' } };
|
|
152
|
+
vi.mocked(fetch)
|
|
153
|
+
.mockResolvedValueOnce(makeResponse(201, JSON.stringify({ id: 101 })))
|
|
154
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issues: [fullIssue] })));
|
|
155
|
+
|
|
156
|
+
const result = await mockServer.callTool('create_issue', {
|
|
157
|
+
summary: 'Created issue', project_id: 1, category: 'General',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(result.isError).toBeUndefined();
|
|
161
|
+
const parsed = JSON.parse(result.content[0]!.text) as typeof fullIssue;
|
|
162
|
+
expect(parsed.summary).toBe('Created issue');
|
|
163
|
+
// Two API calls: POST + GET
|
|
164
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
165
|
+
const getUrl = vi.mocked(fetch).mock.calls[1]![0] as string;
|
|
166
|
+
expect(getUrl).toContain('issues/101');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('returns minimal object when GET fallback fails (issue was already created)', async () => {
|
|
170
|
+
vi.mocked(fetch)
|
|
171
|
+
.mockResolvedValueOnce(makeResponse(201, JSON.stringify({ id: 102 })))
|
|
172
|
+
.mockResolvedValueOnce(makeResponse(500, 'Server Error'));
|
|
173
|
+
|
|
174
|
+
const result = await mockServer.callTool('create_issue', {
|
|
175
|
+
summary: 'Test', project_id: 1, category: 'General',
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(result.isError).toBeUndefined();
|
|
179
|
+
const parsed = JSON.parse(result.content[0]!.text) as { id: number };
|
|
180
|
+
expect(parsed.id).toBe(102);
|
|
181
|
+
});
|
|
182
|
+
|
|
120
183
|
it('respects an explicitly passed severity', async () => {
|
|
121
184
|
vi.mocked(fetch).mockResolvedValue(
|
|
122
185
|
makeResponse(201, JSON.stringify({ issue: { id: 101, summary: 'Test' } }))
|
|
@@ -134,6 +197,98 @@ describe('create_issue', () => {
|
|
|
134
197
|
});
|
|
135
198
|
});
|
|
136
199
|
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// create_issue – handler username
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
describe('create_issue – handler username', () => {
|
|
205
|
+
it('resolves handler username to id from cache and sets handler in body', async () => {
|
|
206
|
+
const cache = makeStubCache([{ id: 7, name: 'dom' }, { id: 8, name: 'jane' }]);
|
|
207
|
+
const server = new MockMcpServer();
|
|
208
|
+
registerIssueTools(server as never, client, cache);
|
|
209
|
+
|
|
210
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
211
|
+
makeResponse(201, JSON.stringify({ issue: { id: 200, summary: 'New issue', handler: { id: 7, name: 'dom' } } }))
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
await server.callTool('create_issue', {
|
|
215
|
+
summary: 'Test', project_id: 1, category: 'General', handler: 'dom',
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as { handler: { id: number } };
|
|
219
|
+
expect(body.handler).toEqual({ id: 7 });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('resolves handler by real_name when name does not match', async () => {
|
|
223
|
+
const cache = makeStubCache([{ id: 9, name: 'jdoe', real_name: 'John Doe' }]);
|
|
224
|
+
const server = new MockMcpServer();
|
|
225
|
+
registerIssueTools(server as never, client, cache);
|
|
226
|
+
|
|
227
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
228
|
+
makeResponse(201, JSON.stringify({ issue: { id: 201, summary: 'New' } }))
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
await server.callTool('create_issue', {
|
|
232
|
+
summary: 'Test', project_id: 1, category: 'General', handler: 'John Doe',
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as { handler: { id: number } };
|
|
236
|
+
expect(body.handler).toEqual({ id: 9 });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('fetches users from API when cache returns null', async () => {
|
|
240
|
+
const cache = makeStubCache(); // returns null from loadIfValid
|
|
241
|
+
const server = new MockMcpServer();
|
|
242
|
+
registerIssueTools(server as never, client, cache);
|
|
243
|
+
|
|
244
|
+
vi.mocked(fetch)
|
|
245
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ users: [{ id: 42, name: 'alice' }] })))
|
|
246
|
+
.mockResolvedValueOnce(makeResponse(201, JSON.stringify({ issue: { id: 202, summary: 'New' } })));
|
|
247
|
+
|
|
248
|
+
await server.callTool('create_issue', {
|
|
249
|
+
summary: 'Test', project_id: 1, category: 'General', handler: 'alice',
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const projectUsersCall = vi.mocked(fetch).mock.calls[0]![0] as string;
|
|
253
|
+
expect(projectUsersCall).toContain('projects/1/users');
|
|
254
|
+
|
|
255
|
+
const createBody = JSON.parse(vi.mocked(fetch).mock.calls[1]![1]!.body as string) as { handler: { id: number } };
|
|
256
|
+
expect(createBody.handler).toEqual({ id: 42 });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('returns error when handler username is not found', async () => {
|
|
260
|
+
const cache = makeStubCache([{ id: 7, name: 'dom' }]);
|
|
261
|
+
const server = new MockMcpServer();
|
|
262
|
+
registerIssueTools(server as never, client, cache);
|
|
263
|
+
|
|
264
|
+
const result = await server.callTool('create_issue', {
|
|
265
|
+
summary: 'Test', project_id: 1, category: 'General', handler: 'nonexistent',
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
expect(result.isError).toBe(true);
|
|
269
|
+
expect(result.content[0]!.text).toContain('nonexistent');
|
|
270
|
+
expect(result.content[0]!.text).toContain('dom');
|
|
271
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('handler_id takes precedence over handler username', async () => {
|
|
275
|
+
const cache = makeStubCache([{ id: 7, name: 'dom' }]);
|
|
276
|
+
const server = new MockMcpServer();
|
|
277
|
+
registerIssueTools(server as never, client, cache);
|
|
278
|
+
|
|
279
|
+
vi.mocked(fetch).mockResolvedValue(
|
|
280
|
+
makeResponse(201, JSON.stringify({ issue: { id: 203, summary: 'New' } }))
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
await server.callTool('create_issue', {
|
|
284
|
+
summary: 'Test', project_id: 1, category: 'General', handler_id: 99, handler: 'dom',
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string) as { handler: { id: number } };
|
|
288
|
+
expect(body.handler).toEqual({ id: 99 });
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
137
292
|
// ---------------------------------------------------------------------------
|
|
138
293
|
// list_issues
|
|
139
294
|
// ---------------------------------------------------------------------------
|
|
@@ -60,6 +60,81 @@ describe('add_relationship', () => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// add_relationship – type_name parameter
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe('add_relationship – type_name', () => {
|
|
68
|
+
it('accepts "related_to" and sends type_id 1', async () => {
|
|
69
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
|
|
70
|
+
|
|
71
|
+
await mockServer.callTool('add_relationship', { issue_id: 10, target_id: 20, type_name: 'related_to' });
|
|
72
|
+
|
|
73
|
+
const body = JSON.parse((vi.mocked(fetch).mock.calls[0]![1] as RequestInit).body as string) as { type: { id: number } };
|
|
74
|
+
expect(body.type.id).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('accepts "related-to" (dash variant) and sends type_id 1', async () => {
|
|
78
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
|
|
79
|
+
|
|
80
|
+
await mockServer.callTool('add_relationship', { issue_id: 10, target_id: 20, type_name: 'related-to' });
|
|
81
|
+
|
|
82
|
+
const body = JSON.parse((vi.mocked(fetch).mock.calls[0]![1] as RequestInit).body as string) as { type: { id: number } };
|
|
83
|
+
expect(body.type.id).toBe(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('accepts "duplicate_of" and sends type_id 0', async () => {
|
|
87
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
|
|
88
|
+
|
|
89
|
+
await mockServer.callTool('add_relationship', { issue_id: 10, target_id: 20, type_name: 'duplicate_of' });
|
|
90
|
+
|
|
91
|
+
const body = JSON.parse((vi.mocked(fetch).mock.calls[0]![1] as RequestInit).body as string) as { type: { id: number } };
|
|
92
|
+
expect(body.type.id).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('accepts "depends_on" as alias for parent_of (type_id 2)', async () => {
|
|
96
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
|
|
97
|
+
|
|
98
|
+
await mockServer.callTool('add_relationship', { issue_id: 10, target_id: 20, type_name: 'depends_on' });
|
|
99
|
+
|
|
100
|
+
const body = JSON.parse((vi.mocked(fetch).mock.calls[0]![1] as RequestInit).body as string) as { type: { id: number } };
|
|
101
|
+
expect(body.type.id).toBe(2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('accepts "blocks" as alias for child_of (type_id 3)', async () => {
|
|
105
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
|
|
106
|
+
|
|
107
|
+
await mockServer.callTool('add_relationship', { issue_id: 10, target_id: 20, type_name: 'blocks' });
|
|
108
|
+
|
|
109
|
+
const body = JSON.parse((vi.mocked(fetch).mock.calls[0]![1] as RequestInit).body as string) as { type: { id: number } };
|
|
110
|
+
expect(body.type.id).toBe(3);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('type_id takes precedence when both type_id and type_name are given', async () => {
|
|
114
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
|
|
115
|
+
|
|
116
|
+
await mockServer.callTool('add_relationship', { issue_id: 10, target_id: 20, type_id: 4, type_name: 'related_to' });
|
|
117
|
+
|
|
118
|
+
const body = JSON.parse((vi.mocked(fetch).mock.calls[0]![1] as RequestInit).body as string) as { type: { id: number } };
|
|
119
|
+
expect(body.type.id).toBe(4);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns error for unknown type_name', async () => {
|
|
123
|
+
const result = await mockServer.callTool('add_relationship', { issue_id: 10, target_id: 20, type_name: 'nonsense' });
|
|
124
|
+
|
|
125
|
+
expect(result.isError).toBe(true);
|
|
126
|
+
expect(result.content[0]!.text).toContain('nonsense');
|
|
127
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('returns error when neither type_id nor type_name is given', async () => {
|
|
131
|
+
const result = await mockServer.callTool('add_relationship', { issue_id: 10, target_id: 20 });
|
|
132
|
+
|
|
133
|
+
expect(result.isError).toBe(true);
|
|
134
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
63
138
|
// ---------------------------------------------------------------------------
|
|
64
139
|
// remove_relationship
|
|
65
140
|
// ---------------------------------------------------------------------------
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
13
13
|
import { MantisClient } from '../../src/client.js';
|
|
14
14
|
import { registerIssueTools } from '../../src/tools/issues.js';
|
|
15
|
+
import { MetadataCache } from '../../src/cache.js';
|
|
15
16
|
import { registerNoteTools } from '../../src/tools/notes.js';
|
|
16
17
|
import { registerFileTools } from '../../src/tools/files.js';
|
|
17
18
|
import { registerMonitorTools } from '../../src/tools/monitors.js';
|
|
@@ -30,7 +31,8 @@ let client: MantisClient;
|
|
|
30
31
|
beforeEach(() => {
|
|
31
32
|
mockServer = new MockMcpServer();
|
|
32
33
|
client = new MantisClient('https://mantis.example.com', 'test-token');
|
|
33
|
-
|
|
34
|
+
const stubCache = { loadIfValid: vi.fn(async () => null) } as unknown as MetadataCache;
|
|
35
|
+
registerIssueTools(mockServer as never, client, stubCache);
|
|
34
36
|
registerNoteTools(mockServer as never, client);
|
|
35
37
|
registerFileTools(mockServer as never, client);
|
|
36
38
|
registerMonitorTools(mockServer as never, client);
|