@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.
@@ -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 EnumResult = Record<string, Array<{ id: number; name: string; label?: string }>>;
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).toEqual({ id: 10, name: 'Feature-Wunsch' });
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
- registerIssueTools(mockServer as never, client);
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);