@dpesch/mantisbt-mcp-server 1.10.3 → 1.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +1 -1
  3. package/server.json +2 -2
  4. package/.gitea/PULL_REQUEST_TEMPLATE.md +0 -17
  5. package/.gitea/workflows/ci.yml +0 -95
  6. package/.github/workflows/ci.yml +0 -32
  7. package/scripts/hooks/pre-push.mjs +0 -220
  8. package/scripts/init.mjs +0 -95
  9. package/scripts/record-fixtures.ts +0 -160
  10. package/tests/cache.test.ts +0 -265
  11. package/tests/client.test.ts +0 -372
  12. package/tests/config.test.ts +0 -263
  13. package/tests/fixtures/get_current_user.json +0 -40
  14. package/tests/fixtures/get_issue.json +0 -133
  15. package/tests/fixtures/get_issue_enums.json +0 -67
  16. package/tests/fixtures/get_issue_fields_sample.json +0 -76
  17. package/tests/fixtures/get_project_categories.json +0 -157
  18. package/tests/fixtures/get_project_versions.json +0 -3
  19. package/tests/fixtures/get_project_versions_with_data.json +0 -52
  20. package/tests/fixtures/list_issues.json +0 -95
  21. package/tests/fixtures/list_projects.json +0 -65
  22. package/tests/helpers/mock-server.ts +0 -166
  23. package/tests/helpers/search-mocks.ts +0 -84
  24. package/tests/prompts/prompts.test.ts +0 -242
  25. package/tests/resources/resources.test.ts +0 -309
  26. package/tests/search/embedder.test.ts +0 -81
  27. package/tests/search/highlight.test.ts +0 -129
  28. package/tests/search/store.test.ts +0 -193
  29. package/tests/search/sync.test.ts +0 -249
  30. package/tests/search/tools.test.ts +0 -661
  31. package/tests/tools/config.test.ts +0 -212
  32. package/tests/tools/files.test.ts +0 -343
  33. package/tests/tools/issues.test.ts +0 -1180
  34. package/tests/tools/metadata.test.ts +0 -509
  35. package/tests/tools/monitors.test.ts +0 -101
  36. package/tests/tools/projects.test.ts +0 -338
  37. package/tests/tools/relationships.test.ts +0 -177
  38. package/tests/tools/string-coercion.test.ts +0 -317
  39. package/tests/tools/users.test.ts +0 -62
  40. package/tests/utils/date-filter.test.ts +0 -169
  41. package/tests/version-hint.test.ts +0 -230
  42. package/tsconfig.build.json +0 -8
  43. package/vitest.config.ts +0 -8
@@ -1,661 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { MantisClient } from '../../src/client.js';
3
- import { registerSearchTools } from '../../src/search/tools.js';
4
- import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
5
- import { makeMockStore, makeMockEmbedder } from '../helpers/search-mocks.js';
6
- import type { Embedder } from '../../src/search/embedder.js';
7
-
8
- // ---------------------------------------------------------------------------
9
- // Setup
10
- // ---------------------------------------------------------------------------
11
-
12
- let mockServer: MockMcpServer;
13
- let client: MantisClient;
14
- let embedder: Embedder;
15
-
16
- beforeEach(() => {
17
- mockServer = new MockMcpServer();
18
- client = new MantisClient('https://mantis.example.com', 'test-token');
19
- embedder = makeMockEmbedder();
20
- vi.stubGlobal('fetch', vi.fn());
21
- });
22
-
23
- afterEach(() => {
24
- vi.unstubAllGlobals();
25
- vi.clearAllMocks();
26
- });
27
-
28
- // ---------------------------------------------------------------------------
29
- // Tool registration
30
- // ---------------------------------------------------------------------------
31
-
32
- describe('registerSearchTools – registration', () => {
33
- it('registers search_issues', () => {
34
- const store = makeMockStore({ itemCount: 0 });
35
- registerSearchTools(mockServer as never, client, store, embedder);
36
- expect(mockServer.hasToolRegistered('search_issues')).toBe(true);
37
- });
38
-
39
- it('registers rebuild_search_index', () => {
40
- const store = makeMockStore({ itemCount: 0 });
41
- registerSearchTools(mockServer as never, client, store, embedder);
42
- expect(mockServer.hasToolRegistered('rebuild_search_index')).toBe(true);
43
- });
44
- });
45
-
46
- // ---------------------------------------------------------------------------
47
- // search_issues – empty store
48
- // ---------------------------------------------------------------------------
49
-
50
- describe('search_issues – empty store', () => {
51
- it('returns error message when store is empty', async () => {
52
- const store = makeMockStore({ itemCount: 0 });
53
- registerSearchTools(mockServer as never, client, store, embedder);
54
-
55
- const result = await mockServer.callTool('search_issues', { query: 'login error', top_n: 5 });
56
-
57
- expect(result.isError).toBe(true);
58
- expect(result.content[0]!.text).toContain('Search index is empty');
59
- });
60
- });
61
-
62
- // ---------------------------------------------------------------------------
63
- // search_issues – with results
64
- // ---------------------------------------------------------------------------
65
-
66
- describe('search_issues – with results', () => {
67
- it('returns a JSON array with id and score', async () => {
68
- const store = makeMockStore({ itemCount: 3 });
69
- registerSearchTools(mockServer as never, client, store, embedder);
70
-
71
- const result = await mockServer.callTool('search_issues', { query: 'login error', top_n: 3 });
72
-
73
- expect(result.isError).toBeUndefined();
74
- const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number; score: number }>;
75
- expect(Array.isArray(parsed)).toBe(true);
76
- expect(parsed.length).toBeGreaterThan(0);
77
- expect(typeof parsed[0]!.id).toBe('number');
78
- expect(typeof parsed[0]!.score).toBe('number');
79
- });
80
-
81
- it('respects the top_n parameter', async () => {
82
- const store = makeMockStore({ itemCount: 10 });
83
- registerSearchTools(mockServer as never, client, store, embedder);
84
-
85
- const result = await mockServer.callTool('search_issues', { query: 'bug', top_n: 2 });
86
-
87
- const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number; score: number }>;
88
- expect(parsed.length).toBeLessThanOrEqual(2);
89
- });
90
-
91
- it('calls embedder.embed with the query', async () => {
92
- const store = makeMockStore({ itemCount: 1 });
93
- registerSearchTools(mockServer as never, client, store, embedder);
94
-
95
- await mockServer.callTool('search_issues', { query: 'performance issue', top_n: 5 });
96
-
97
- expect(embedder.embed).toHaveBeenCalledWith('performance issue');
98
- });
99
- });
100
-
101
- // ---------------------------------------------------------------------------
102
- // rebuild_search_index – full rebuild
103
- // ---------------------------------------------------------------------------
104
-
105
- describe('rebuild_search_index – full: true', () => {
106
- it('clears the store and resets lastSyncedAt before syncing', async () => {
107
- const store = makeMockStore({ itemCount: 5 });
108
- registerSearchTools(mockServer as never, client, store, embedder);
109
-
110
- vi.mocked(fetch).mockResolvedValue(
111
- makeResponse(200, JSON.stringify({ issues: [], total_count: 0 }))
112
- );
113
-
114
- await mockServer.callTool('rebuild_search_index', { full: true });
115
-
116
- expect(store.clear).toHaveBeenCalled();
117
- expect(store.resetLastSyncedAt).toHaveBeenCalled();
118
- });
119
-
120
- it('returns indexed, skipped, duration_ms in the response', async () => {
121
- const store = makeMockStore({ itemCount: 0 });
122
- registerSearchTools(mockServer as never, client, store, embedder);
123
-
124
- vi.mocked(fetch).mockResolvedValue(
125
- makeResponse(200, JSON.stringify({ issues: [], total_count: 0 }))
126
- );
127
-
128
- const result = await mockServer.callTool('rebuild_search_index', { full: false });
129
-
130
- expect(result.isError).toBeUndefined();
131
- const parsed = JSON.parse(result.content[0]!.text) as {
132
- indexed: number;
133
- skipped: number;
134
- duration_ms: number;
135
- };
136
- expect(typeof parsed.indexed).toBe('number');
137
- expect(typeof parsed.skipped).toBe('number');
138
- expect(typeof parsed.duration_ms).toBe('number');
139
- });
140
- });
141
-
142
- // ---------------------------------------------------------------------------
143
- // rebuild_search_index – incremental (full: false)
144
- // ---------------------------------------------------------------------------
145
-
146
- describe('rebuild_search_index – full: false', () => {
147
- it('does NOT clear the store', async () => {
148
- const store = makeMockStore({ itemCount: 0 });
149
- registerSearchTools(mockServer as never, client, store, embedder);
150
-
151
- vi.mocked(fetch).mockResolvedValue(
152
- makeResponse(200, JSON.stringify({ issues: [], total_count: 0 }))
153
- );
154
-
155
- await mockServer.callTool('rebuild_search_index', { full: false });
156
-
157
- expect(store.clear).not.toHaveBeenCalled();
158
- expect(store.resetLastSyncedAt).not.toHaveBeenCalled();
159
- });
160
- });
161
-
162
- // ---------------------------------------------------------------------------
163
- // search_issues – select parameter
164
- // ---------------------------------------------------------------------------
165
-
166
- describe('search_issues – select parameter', () => {
167
- it('returns plain {id, score, view_url} 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; view_url: string }>;
175
- expect(parsed[0]).toEqual(expect.objectContaining({ id: expect.any(Number), score: expect.any(Number) }));
176
- expect(Object.keys(parsed[0]!)).toEqual(['id', 'score', 'view_url']);
177
- expect(parsed[0]!.view_url).toBe(`https://mantis.example.com/view.php?id=${parsed[0]!.id}`);
178
- });
179
-
180
- it('fetches issues and projects requested fields when select is provided', async () => {
181
- const store = makeMockStore({ itemCount: 2 });
182
- registerSearchTools(mockServer as never, client, store, embedder);
183
-
184
- vi.mocked(fetch)
185
- .mockResolvedValueOnce(
186
- makeResponse(200, JSON.stringify({ issues: [{ id: 1, summary: 'Login bug', status: { id: 10, name: 'new' }, priority: { id: 30, name: 'normal' } }] }))
187
- )
188
- .mockResolvedValueOnce(
189
- makeResponse(200, JSON.stringify({ issues: [{ id: 2, summary: 'Crash on save', status: { id: 50, name: 'assigned' }, priority: { id: 40, name: 'high' } }] }))
190
- );
191
-
192
- const result = await mockServer.callTool('search_issues', { query: 'test', top_n: 2, select: 'summary,status' });
193
-
194
- expect(result.isError).toBeUndefined();
195
- const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
196
- expect(parsed).toHaveLength(2);
197
- // id and score always present
198
- expect(parsed[0]).toHaveProperty('id');
199
- expect(parsed[0]).toHaveProperty('score');
200
- // requested fields present
201
- expect(parsed[0]).toHaveProperty('summary', 'Login bug');
202
- expect(parsed[0]).toHaveProperty('status');
203
- // non-requested field absent
204
- expect(parsed[0]).not.toHaveProperty('priority');
205
- });
206
-
207
- it('id and score are always included even when not listed in select', async () => {
208
- const store = makeMockStore({ itemCount: 1 });
209
- registerSearchTools(mockServer as never, client, store, embedder);
210
-
211
- vi.mocked(fetch).mockResolvedValueOnce(
212
- makeResponse(200, JSON.stringify({ issues: [{ id: 1, summary: 'Test issue' }] }))
213
- );
214
-
215
- const result = await mockServer.callTool('search_issues', { query: 'test', top_n: 1, select: 'summary' });
216
-
217
- const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
218
- expect(parsed[0]).toHaveProperty('id', 1);
219
- expect(parsed[0]).toHaveProperty('score');
220
- expect(parsed[0]).toHaveProperty('summary', 'Test issue');
221
- });
222
-
223
- it('falls back to {id, score} when issue fetch fails', async () => {
224
- const store = makeMockStore({ itemCount: 2 });
225
- registerSearchTools(mockServer as never, client, store, embedder);
226
-
227
- vi.mocked(fetch)
228
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify({ issues: [{ id: 1, summary: 'OK issue' }] })))
229
- .mockResolvedValueOnce(makeResponse(500, 'Internal Server Error'));
230
-
231
- const result = await mockServer.callTool('search_issues', { query: 'test', top_n: 2, select: 'summary' });
232
-
233
- expect(result.isError).toBeUndefined();
234
- const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
235
- expect(parsed).toHaveLength(2);
236
- // First item enriched
237
- expect(parsed[0]).toHaveProperty('summary');
238
- // Second item fallback — id, score, and view_url
239
- expect(Object.keys(parsed[1]!).sort()).toEqual(['id', 'score', 'view_url']);
240
- });
241
-
242
- it('omits non-existent fields silently', async () => {
243
- const store = makeMockStore({ itemCount: 1 });
244
- registerSearchTools(mockServer as never, client, store, embedder);
245
-
246
- vi.mocked(fetch).mockResolvedValueOnce(
247
- makeResponse(200, JSON.stringify({ issues: [{ id: 1, summary: 'Test' }] }))
248
- );
249
-
250
- const result = await mockServer.callTool('search_issues', { query: 'test', top_n: 1, select: 'summary,nonexistent_field' });
251
-
252
- const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
253
- expect(parsed[0]).toHaveProperty('summary', 'Test');
254
- expect(parsed[0]).not.toHaveProperty('nonexistent_field');
255
- });
256
-
257
- it('makes one API call per result when select is provided', async () => {
258
- const store = makeMockStore({ itemCount: 3 });
259
- registerSearchTools(mockServer as never, client, store, embedder);
260
-
261
- vi.mocked(fetch).mockResolvedValue(
262
- makeResponse(200, JSON.stringify({ issues: [{ id: 1, summary: 'Issue' }] }))
263
- );
264
-
265
- await mockServer.callTool('search_issues', { query: 'test', top_n: 3, select: 'summary' });
266
-
267
- expect(fetch).toHaveBeenCalledTimes(3);
268
- });
269
-
270
- it('makes no API calls when select is not provided', async () => {
271
- const store = makeMockStore({ itemCount: 3 });
272
- registerSearchTools(mockServer as never, client, store, embedder);
273
-
274
- await mockServer.callTool('search_issues', { query: 'test', top_n: 3 });
275
-
276
- expect(fetch).not.toHaveBeenCalled();
277
- });
278
- });
279
-
280
- // ---------------------------------------------------------------------------
281
- // get_search_index_status – registration
282
- // ---------------------------------------------------------------------------
283
-
284
- describe('get_search_index_status – registration', () => {
285
- it('is registered', () => {
286
- const store = makeMockStore({ itemCount: 0 });
287
- registerSearchTools(mockServer as never, client, store, embedder);
288
- expect(mockServer.hasToolRegistered('get_search_index_status')).toBe(true);
289
- });
290
- });
291
-
292
- // ---------------------------------------------------------------------------
293
- // get_search_index_status – reads from store, no API call
294
- // ---------------------------------------------------------------------------
295
-
296
- describe('get_search_index_status – with stored total', () => {
297
- it('returns summary, indexed, total, percent and last_synced_at from store', async () => {
298
- const store = makeMockStore({
299
- itemCount: 42,
300
- lastSyncedAt: '2026-03-16T10:00:00.000Z',
301
- lastKnownTotal: 100,
302
- });
303
- registerSearchTools(mockServer as never, client, store, embedder);
304
-
305
- const result = await mockServer.callTool('get_search_index_status', {});
306
-
307
- expect(result.isError).toBeUndefined();
308
- const parsed = JSON.parse(result.content[0]!.text) as {
309
- summary: string;
310
- indexed: number;
311
- total: number;
312
- percent: number;
313
- last_synced_at: string;
314
- };
315
- expect(parsed.indexed).toBe(42);
316
- expect(parsed.total).toBe(100);
317
- expect(parsed.percent).toBe(42);
318
- expect(parsed.summary).toBe('42/100 (42 %)');
319
- expect(parsed.last_synced_at).toBe('2026-03-16T10:00:00.000Z');
320
- });
321
-
322
- it('makes no API call', async () => {
323
- const store = makeMockStore({ itemCount: 0, lastKnownTotal: 50 });
324
- registerSearchTools(mockServer as never, client, store, embedder);
325
-
326
- await mockServer.callTool('get_search_index_status', {});
327
-
328
- expect(fetch).not.toHaveBeenCalled();
329
- });
330
- });
331
-
332
- // ---------------------------------------------------------------------------
333
- // get_search_index_status – edge cases
334
- // ---------------------------------------------------------------------------
335
-
336
- // ---------------------------------------------------------------------------
337
- // search_issues – date filters
338
- // ---------------------------------------------------------------------------
339
-
340
- describe('search_issues – updated_after filter (no select, uses store metadata)', () => {
341
- it('returns only results whose store metadata updated_at is after the threshold', async () => {
342
- const store = makeMockStore({
343
- items: [
344
- { id: 1, score: 0.9, updated_at: '2026-03-26T00:00:00Z' }, // pass
345
- { id: 2, score: 0.8, updated_at: '2026-03-23T00:00:00Z' }, // fail
346
- { id: 3, score: 0.7, updated_at: '2026-03-25T12:00:00Z' }, // pass
347
- ],
348
- });
349
- registerSearchTools(mockServer as never, client, store, embedder);
350
-
351
- const result = await mockServer.callTool('search_issues', {
352
- query: 'test',
353
- top_n: 10,
354
- updated_after: '2026-03-25T00:00:00Z',
355
- });
356
-
357
- expect(result.isError).toBeUndefined();
358
- const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number }>;
359
- expect(parsed.map(r => r.id)).toEqual([1, 3]);
360
- });
361
-
362
- it('makes no API calls when filtering via store metadata (no select)', async () => {
363
- const store = makeMockStore({
364
- items: [{ id: 1, updated_at: '2026-03-26T00:00:00Z' }],
365
- });
366
- registerSearchTools(mockServer as never, client, store, embedder);
367
-
368
- await mockServer.callTool('search_issues', {
369
- query: 'test',
370
- top_n: 5,
371
- updated_after: '2026-03-25T00:00:00Z',
372
- });
373
-
374
- expect(fetch).not.toHaveBeenCalled();
375
- });
376
-
377
- it('excludes results with no updated_at in store metadata', async () => {
378
- const store = makeMockStore({
379
- items: [
380
- { id: 1, score: 0.9 }, // no updated_at → excluded
381
- { id: 2, score: 0.8, updated_at: '2026-03-26T00:00:00Z' }, // pass
382
- ],
383
- });
384
- registerSearchTools(mockServer as never, client, store, embedder);
385
-
386
- const result = await mockServer.callTool('search_issues', {
387
- query: 'test',
388
- top_n: 10,
389
- updated_after: '2026-03-25T00:00:00Z',
390
- });
391
-
392
- const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number }>;
393
- expect(parsed.map(r => r.id)).toEqual([2]);
394
- });
395
- });
396
-
397
- describe('search_issues – updated_after filter (with select, uses fetched issue data)', () => {
398
- it('returns only results whose fetched updated_at is after the threshold', async () => {
399
- const store = makeMockStore({
400
- items: [
401
- { id: 1, score: 0.9 },
402
- { id: 2, score: 0.8 },
403
- ],
404
- });
405
- registerSearchTools(mockServer as never, client, store, embedder);
406
-
407
- vi.mocked(fetch)
408
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify({
409
- issues: [{ id: 1, summary: 'Recent bug', updated_at: '2026-03-26T00:00:00Z' }],
410
- })))
411
- .mockResolvedValueOnce(makeResponse(200, JSON.stringify({
412
- issues: [{ id: 2, summary: 'Old bug', updated_at: '2026-03-20T00:00:00Z' }],
413
- })));
414
-
415
- const result = await mockServer.callTool('search_issues', {
416
- query: 'test',
417
- top_n: 10,
418
- select: 'summary',
419
- updated_after: '2026-03-25T00:00:00Z',
420
- });
421
-
422
- expect(result.isError).toBeUndefined();
423
- const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number; summary: string }>;
424
- expect(parsed).toHaveLength(1);
425
- expect(parsed[0]!.id).toBe(1);
426
- expect(parsed[0]!.summary).toBe('Recent bug');
427
- });
428
- });
429
-
430
- describe('get_search_index_status – edge cases', () => {
431
- it('returns 0 % when stored total is 0', async () => {
432
- const store = makeMockStore({ itemCount: 0, lastKnownTotal: 0 });
433
- registerSearchTools(mockServer as never, client, store, embedder);
434
-
435
- const result = await mockServer.callTool('get_search_index_status', {});
436
-
437
- const parsed = JSON.parse(result.content[0]!.text) as { percent: number; summary: string };
438
- expect(parsed.percent).toBe(0);
439
- expect(parsed.summary).toBe('0/0 (0 %)');
440
- });
441
-
442
- it('returns 100 % when all issues are indexed', async () => {
443
- const store = makeMockStore({ itemCount: 7842, lastKnownTotal: 7842 });
444
- registerSearchTools(mockServer as never, client, store, embedder);
445
-
446
- const result = await mockServer.callTool('get_search_index_status', {});
447
-
448
- const parsed = JSON.parse(result.content[0]!.text) as { percent: number; summary: string };
449
- expect(parsed.percent).toBe(100);
450
- expect(parsed.summary).toBe('7842/7842 (100 %)');
451
- });
452
-
453
- it('returns total unknown when no sync has run yet', async () => {
454
- const store = makeMockStore({ itemCount: 5, lastKnownTotal: null });
455
- registerSearchTools(mockServer as never, client, store, embedder);
456
-
457
- const result = await mockServer.callTool('get_search_index_status', {});
458
-
459
- expect(result.isError).toBeUndefined();
460
- const parsed = JSON.parse(result.content[0]!.text) as {
461
- total: null;
462
- percent: null;
463
- summary: string;
464
- };
465
- expect(parsed.total).toBeNull();
466
- expect(parsed.percent).toBeNull();
467
- expect(parsed.summary).toContain('total unknown');
468
- });
469
- });
470
-
471
- // ---------------------------------------------------------------------------
472
- // search_issues – highlight parameter
473
- // ---------------------------------------------------------------------------
474
-
475
- describe('search_issues – highlight: true (no select, uses store metadata)', () => {
476
- it('adds highlights field with bolded terms from store metadata summary', async () => {
477
- const store = makeMockStore({
478
- items: [{ id: 1, score: 0.9 }],
479
- });
480
- vi.mocked(store.getItem).mockResolvedValue({
481
- id: 1,
482
- vector: [],
483
- metadata: { summary: 'Login error occurred', description: 'The login fails with error code 500.' },
484
- });
485
- registerSearchTools(mockServer as never, client, store, embedder);
486
-
487
- const result = await mockServer.callTool('search_issues', {
488
- query: 'login error',
489
- top_n: 1,
490
- highlight: true,
491
- });
492
-
493
- expect(result.isError).toBeUndefined();
494
- const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
495
- expect(parsed[0]).toHaveProperty('highlights');
496
- const highlights = parsed[0]!['highlights'] as Record<string, string>;
497
- expect(highlights['summary']).toContain('**');
498
- });
499
-
500
- it('omits highlights field when no query terms match', async () => {
501
- const store = makeMockStore({
502
- items: [{ id: 1, score: 0.9 }],
503
- });
504
- vi.mocked(store.getItem).mockResolvedValue({
505
- id: 1,
506
- vector: [],
507
- metadata: { summary: 'Unrelated issue', description: undefined },
508
- });
509
- registerSearchTools(mockServer as never, client, store, embedder);
510
-
511
- const result = await mockServer.callTool('search_issues', {
512
- query: 'xyzzy',
513
- top_n: 1,
514
- highlight: true,
515
- });
516
-
517
- const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
518
- expect(parsed[0]).not.toHaveProperty('highlights');
519
- });
520
-
521
- it('does not call store.getItem for highlighting when highlight is false', async () => {
522
- const store = makeMockStore({ itemCount: 2 });
523
- registerSearchTools(mockServer as never, client, store, embedder);
524
-
525
- await mockServer.callTool('search_issues', { query: 'login', top_n: 2 });
526
-
527
- // getItem should NOT have been called (no date filter, no highlight)
528
- expect(store.getItem).not.toHaveBeenCalled();
529
- });
530
-
531
- it('still returns id and score alongside highlights', async () => {
532
- const store = makeMockStore({
533
- items: [{ id: 42, score: 0.85 }],
534
- });
535
- vi.mocked(store.getItem).mockResolvedValue({
536
- id: 42,
537
- vector: [],
538
- metadata: { summary: 'Login timeout issue' },
539
- });
540
- registerSearchTools(mockServer as never, client, store, embedder);
541
-
542
- const result = await mockServer.callTool('search_issues', {
543
- query: 'login',
544
- top_n: 1,
545
- highlight: true,
546
- });
547
-
548
- const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
549
- expect(parsed[0]).toHaveProperty('id', 42);
550
- expect(parsed[0]).toHaveProperty('score');
551
- expect(parsed[0]).toHaveProperty('highlights');
552
- });
553
- });
554
-
555
- describe('search_issues – highlight: true (with select)', () => {
556
- it('highlights summary from fetched issue when summary is in select', async () => {
557
- const store = makeMockStore({ itemCount: 1 });
558
- registerSearchTools(mockServer as never, client, store, embedder);
559
-
560
- vi.mocked(fetch).mockResolvedValueOnce(
561
- makeResponse(200, JSON.stringify({
562
- issues: [{ id: 1, summary: 'Login error in dashboard', status: { id: 10, name: 'new' } }],
563
- }))
564
- );
565
-
566
- const result = await mockServer.callTool('search_issues', {
567
- query: 'login error',
568
- top_n: 1,
569
- select: 'summary,status',
570
- highlight: true,
571
- });
572
-
573
- expect(result.isError).toBeUndefined();
574
- const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
575
- expect(parsed[0]).toHaveProperty('highlights');
576
- const highlights = parsed[0]!['highlights'] as Record<string, string>;
577
- expect(highlights['summary']).toContain('**Login**');
578
- });
579
-
580
- it('falls back to store metadata for highlighting when API fetch fails', async () => {
581
- const store = makeMockStore({ items: [{ id: 1, score: 0.9 }] });
582
- vi.mocked(store.getItem).mockResolvedValue({
583
- id: 1,
584
- vector: [],
585
- metadata: { summary: 'Login crash issue' },
586
- });
587
- registerSearchTools(mockServer as never, client, store, embedder);
588
-
589
- vi.mocked(fetch).mockResolvedValueOnce(makeResponse(500, 'Server Error'));
590
-
591
- const result = await mockServer.callTool('search_issues', {
592
- query: 'login',
593
- top_n: 1,
594
- select: 'summary',
595
- highlight: true,
596
- });
597
-
598
- // Falls back to {id, score} but we still try to add highlights from store metadata
599
- const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
600
- expect(parsed[0]).toHaveProperty('id', 1);
601
- });
602
- });
603
-
604
- describe('search_issues – highlight: true combined with date filter', () => {
605
- it('returns highlights only for date-filtered results (no select)', async () => {
606
- const store = makeMockStore({
607
- items: [
608
- { id: 1, score: 0.9, updated_at: '2026-03-26T00:00:00Z' }, // passes filter
609
- { id: 2, score: 0.8, updated_at: '2026-03-20T00:00:00Z' }, // filtered out
610
- ],
611
- });
612
- vi.mocked(store.getItem).mockImplementation(async (id: number) => ({
613
- id,
614
- vector: [],
615
- metadata: {
616
- summary: id === 1 ? 'Login crash issue' : 'Old login bug',
617
- updated_at: id === 1 ? '2026-03-26T00:00:00Z' : '2026-03-20T00:00:00Z',
618
- },
619
- }));
620
- registerSearchTools(mockServer as never, client, store, embedder);
621
-
622
- const result = await mockServer.callTool('search_issues', {
623
- query: 'login',
624
- top_n: 10,
625
- highlight: true,
626
- updated_after: '2026-03-25T00:00:00Z',
627
- });
628
-
629
- const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number }>;
630
- expect(parsed).toHaveLength(1);
631
- expect(parsed[0]!.id).toBe(1);
632
- });
633
- });
634
-
635
- // ---------------------------------------------------------------------------
636
- // string-coercion – highlight as string
637
- // ---------------------------------------------------------------------------
638
-
639
- describe('string-coercion – search_issues highlight as string', () => {
640
- it('accepts highlight "true" as boolean true', async () => {
641
- const store = makeMockStore({
642
- items: [{ id: 1, score: 0.9 }],
643
- });
644
- vi.mocked(store.getItem).mockResolvedValue({
645
- id: 1,
646
- vector: [],
647
- metadata: { summary: 'Login error occurred', description: 'The login fails.' },
648
- });
649
- registerSearchTools(mockServer as never, client, store, embedder);
650
-
651
- const result = await mockServer.callTool(
652
- 'search_issues',
653
- { query: 'login error', top_n: 1, highlight: 'true' },
654
- { validate: true },
655
- );
656
-
657
- expect(result.isError).toBeUndefined();
658
- const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
659
- expect(parsed[0]).toHaveProperty('highlights');
660
- });
661
- });