@dpesch/mantisbt-mcp-server 1.8.2 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,10 +12,29 @@ export const MOCK_VECTOR = Array(384).fill(0.1) as number[];
12
12
  // makeMockStore
13
13
  // ---------------------------------------------------------------------------
14
14
 
15
- export function makeMockStore(options?: { lastSyncedAt?: string | null; itemCount?: number; lastKnownTotal?: number | null }): VectorStore {
15
+ export interface MockStoreItem {
16
+ id: number;
17
+ score?: number;
18
+ updated_at?: string;
19
+ }
20
+
21
+ export function makeMockStore(options?: {
22
+ lastSyncedAt?: string | null;
23
+ itemCount?: number;
24
+ lastKnownTotal?: number | null;
25
+ items?: MockStoreItem[];
26
+ }): VectorStore {
16
27
  const lastSyncedAt = options?.lastSyncedAt ?? null;
28
+ const seedItems = options?.items ?? null;
17
29
  const addedItems: VectorStoreItem[] = [];
18
- let count = options?.itemCount ?? 0;
30
+ const itemMap = new Map<number, VectorStoreItem>(
31
+ (seedItems ?? []).map(i => [i.id, {
32
+ id: i.id,
33
+ vector: MOCK_VECTOR,
34
+ metadata: { summary: `Issue ${i.id}`, updated_at: i.updated_at },
35
+ }])
36
+ );
37
+ let count = options?.itemCount ?? seedItems?.length ?? 0;
19
38
 
20
39
  return {
21
40
  add: vi.fn(async (item: VectorStoreItem) => {
@@ -28,12 +47,16 @@ export function makeMockStore(options?: { lastSyncedAt?: string | null; itemCoun
28
47
  }
29
48
  count += items.length;
30
49
  }),
31
- search: vi.fn(async (_vec: number[], topN: number) =>
32
- Array.from({ length: Math.min(topN, count) }, (_, i) => ({
50
+ search: vi.fn(async (_vec: number[], topN: number) => {
51
+ if (seedItems) {
52
+ return seedItems.slice(0, topN).map(i => ({ id: i.id, score: i.score ?? 0.9 }));
53
+ }
54
+ return Array.from({ length: Math.min(topN, count) }, (_, i) => ({
33
55
  id: i + 1,
34
56
  score: 1 - i * 0.1,
35
- }))
36
- ),
57
+ }));
58
+ }),
59
+ getItem: vi.fn(async (id: number) => itemMap.get(id) ?? null),
37
60
  delete: vi.fn(async () => {}),
38
61
  count: vi.fn(async () => count),
39
62
  clear: vi.fn(async () => {
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractTerms, highlightText, extractSnippet } from '../../src/search/highlight.js';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // extractTerms
6
+ // ---------------------------------------------------------------------------
7
+
8
+ describe('extractTerms', () => {
9
+ it('splits by whitespace and returns terms of length >= 3', () => {
10
+ expect(extractTerms('login error')).toEqual(['login', 'error']);
11
+ });
12
+
13
+ it('filters out terms shorter than 3 characters', () => {
14
+ expect(extractTerms('a db login')).toEqual(['login']);
15
+ });
16
+
17
+ it('deduplicates terms case-insensitively', () => {
18
+ const terms = extractTerms('Login login LOGIN');
19
+ expect(terms).toHaveLength(1);
20
+ expect(terms[0]!.toLowerCase()).toBe('login');
21
+ });
22
+
23
+ it('sorts longest terms first to prevent partial-match overlap', () => {
24
+ const terms = extractTerms('err error errors');
25
+ expect(terms[0]).toBe('errors');
26
+ expect(terms[1]).toBe('error');
27
+ });
28
+
29
+ it('returns empty array for empty query', () => {
30
+ expect(extractTerms('')).toEqual([]);
31
+ });
32
+
33
+ it('returns empty array when all terms are too short', () => {
34
+ expect(extractTerms('a ab')).toEqual([]);
35
+ });
36
+
37
+ it('trims whitespace', () => {
38
+ expect(extractTerms(' login error ')).toEqual(['login', 'error']);
39
+ });
40
+ });
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // highlightText
44
+ // ---------------------------------------------------------------------------
45
+
46
+ describe('highlightText', () => {
47
+ it('wraps a matching term in **bold**', () => {
48
+ expect(highlightText('Login failed', ['login'])).toBe('**Login** failed');
49
+ });
50
+
51
+ it('is case-insensitive', () => {
52
+ expect(highlightText('CRASH on startup', ['crash'])).toBe('**CRASH** on startup');
53
+ });
54
+
55
+ it('highlights multiple terms', () => {
56
+ const result = highlightText('Login error occurred', ['login', 'error']);
57
+ expect(result).toBe('**Login** **error** occurred');
58
+ });
59
+
60
+ it('returns original text when no terms match', () => {
61
+ expect(highlightText('Something unrelated', ['crash'])).toBe('Something unrelated');
62
+ });
63
+
64
+ it('returns original text when terms array is empty', () => {
65
+ expect(highlightText('Login failed', [])).toBe('Login failed');
66
+ });
67
+
68
+ it('does not match term as substring within a word (word-boundary-aware)', () => {
69
+ // "or" should NOT match inside "error"
70
+ expect(highlightText('error occurred', ['or'])).toBe('error occurred');
71
+ });
72
+
73
+ it('escapes special regex characters in terms', () => {
74
+ // Term with special regex chars should not throw
75
+ expect(() => highlightText('test (foo)', ['(foo)'])).not.toThrow();
76
+ });
77
+
78
+ it('highlights all occurrences of a term', () => {
79
+ const result = highlightText('login and login again', ['login']);
80
+ expect(result).toBe('**login** and **login** again');
81
+ });
82
+ });
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // extractSnippet
86
+ // ---------------------------------------------------------------------------
87
+
88
+ describe('extractSnippet', () => {
89
+ it('returns full text highlighted when text is short', () => {
90
+ const text = 'Login error on startup';
91
+ const result = extractSnippet(text, ['login']);
92
+ expect(result).toBe('**Login** error on startup');
93
+ });
94
+
95
+ it('returns first ~300 chars around the first match for long text', () => {
96
+ const prefix = 'x'.repeat(200);
97
+ const text = `${prefix} login error ${' unrelated text'.repeat(30)}`;
98
+ const result = extractSnippet(text, ['login']);
99
+ expect(result).toContain('**login**');
100
+ expect(result.length).toBeLessThanOrEqual(350); // snippet + some overhead
101
+ });
102
+
103
+ it('centers snippet around first match', () => {
104
+ const padding = 'word '.repeat(60); // ~300 chars before the match
105
+ const text = `${padding}crash happens here ${'and more text '.repeat(30)}`;
106
+ const result = extractSnippet(text, ['crash']);
107
+ expect(result).toContain('**crash**');
108
+ });
109
+
110
+ it('returns first 300 chars (no highlight) when no term matches', () => {
111
+ const text = 'a'.repeat(600);
112
+ const result = extractSnippet(text, ['nomatch']);
113
+ expect(result).toBe('a'.repeat(300) + '…');
114
+ });
115
+
116
+ it('returns full text (no truncation) when text is shorter than 300 chars and no match', () => {
117
+ const text = 'Short text with no match';
118
+ const result = extractSnippet(text, ['nomatch']);
119
+ expect(result).toBe(text);
120
+ });
121
+
122
+ it('respects custom contextChars parameter', () => {
123
+ const padding = 'x'.repeat(100);
124
+ const text = `${padding} crash ${padding}`;
125
+ const result = extractSnippet(text, ['crash'], 50);
126
+ expect(result).toContain('**crash**');
127
+ expect(result.length).toBeLessThanOrEqual(150);
128
+ });
129
+ });
@@ -332,6 +332,100 @@ describe('get_search_index_status – with stored total', () => {
332
332
  // get_search_index_status – edge cases
333
333
  // ---------------------------------------------------------------------------
334
334
 
335
+ // ---------------------------------------------------------------------------
336
+ // search_issues – date filters
337
+ // ---------------------------------------------------------------------------
338
+
339
+ describe('search_issues – updated_after filter (no select, uses store metadata)', () => {
340
+ it('returns only results whose store metadata updated_at is after the threshold', async () => {
341
+ const store = makeMockStore({
342
+ items: [
343
+ { id: 1, score: 0.9, updated_at: '2026-03-26T00:00:00Z' }, // pass
344
+ { id: 2, score: 0.8, updated_at: '2026-03-23T00:00:00Z' }, // fail
345
+ { id: 3, score: 0.7, updated_at: '2026-03-25T12:00:00Z' }, // pass
346
+ ],
347
+ });
348
+ registerSearchTools(mockServer as never, client, store, embedder);
349
+
350
+ const result = await mockServer.callTool('search_issues', {
351
+ query: 'test',
352
+ top_n: 10,
353
+ updated_after: '2026-03-25T00:00:00Z',
354
+ });
355
+
356
+ expect(result.isError).toBeUndefined();
357
+ const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number }>;
358
+ expect(parsed.map(r => r.id)).toEqual([1, 3]);
359
+ });
360
+
361
+ it('makes no API calls when filtering via store metadata (no select)', async () => {
362
+ const store = makeMockStore({
363
+ items: [{ id: 1, updated_at: '2026-03-26T00:00:00Z' }],
364
+ });
365
+ registerSearchTools(mockServer as never, client, store, embedder);
366
+
367
+ await mockServer.callTool('search_issues', {
368
+ query: 'test',
369
+ top_n: 5,
370
+ updated_after: '2026-03-25T00:00:00Z',
371
+ });
372
+
373
+ expect(fetch).not.toHaveBeenCalled();
374
+ });
375
+
376
+ it('excludes results with no updated_at in store metadata', async () => {
377
+ const store = makeMockStore({
378
+ items: [
379
+ { id: 1, score: 0.9 }, // no updated_at → excluded
380
+ { id: 2, score: 0.8, updated_at: '2026-03-26T00:00:00Z' }, // pass
381
+ ],
382
+ });
383
+ registerSearchTools(mockServer as never, client, store, embedder);
384
+
385
+ const result = await mockServer.callTool('search_issues', {
386
+ query: 'test',
387
+ top_n: 10,
388
+ updated_after: '2026-03-25T00:00:00Z',
389
+ });
390
+
391
+ const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number }>;
392
+ expect(parsed.map(r => r.id)).toEqual([2]);
393
+ });
394
+ });
395
+
396
+ describe('search_issues – updated_after filter (with select, uses fetched issue data)', () => {
397
+ it('returns only results whose fetched updated_at is after the threshold', async () => {
398
+ const store = makeMockStore({
399
+ items: [
400
+ { id: 1, score: 0.9 },
401
+ { id: 2, score: 0.8 },
402
+ ],
403
+ });
404
+ registerSearchTools(mockServer as never, client, store, embedder);
405
+
406
+ vi.mocked(fetch)
407
+ .mockResolvedValueOnce(makeResponse(200, JSON.stringify({
408
+ issues: [{ id: 1, summary: 'Recent bug', updated_at: '2026-03-26T00:00:00Z' }],
409
+ })))
410
+ .mockResolvedValueOnce(makeResponse(200, JSON.stringify({
411
+ issues: [{ id: 2, summary: 'Old bug', updated_at: '2026-03-20T00:00:00Z' }],
412
+ })));
413
+
414
+ const result = await mockServer.callTool('search_issues', {
415
+ query: 'test',
416
+ top_n: 10,
417
+ select: 'summary',
418
+ updated_after: '2026-03-25T00:00:00Z',
419
+ });
420
+
421
+ expect(result.isError).toBeUndefined();
422
+ const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number; summary: string }>;
423
+ expect(parsed).toHaveLength(1);
424
+ expect(parsed[0]!.id).toBe(1);
425
+ expect(parsed[0]!.summary).toBe('Recent bug');
426
+ });
427
+ });
428
+
335
429
  describe('get_search_index_status – edge cases', () => {
336
430
  it('returns 0 % when stored total is 0', async () => {
337
431
  const store = makeMockStore({ itemCount: 0, lastKnownTotal: 0 });
@@ -372,3 +466,167 @@ describe('get_search_index_status – edge cases', () => {
372
466
  expect(parsed.summary).toContain('total unknown');
373
467
  });
374
468
  });
469
+
470
+ // ---------------------------------------------------------------------------
471
+ // search_issues – highlight parameter
472
+ // ---------------------------------------------------------------------------
473
+
474
+ describe('search_issues – highlight: true (no select, uses store metadata)', () => {
475
+ it('adds highlights field with bolded terms from store metadata summary', async () => {
476
+ const store = makeMockStore({
477
+ items: [{ id: 1, score: 0.9 }],
478
+ });
479
+ vi.mocked(store.getItem).mockResolvedValue({
480
+ id: 1,
481
+ vector: [],
482
+ metadata: { summary: 'Login error occurred', description: 'The login fails with error code 500.' },
483
+ });
484
+ registerSearchTools(mockServer as never, client, store, embedder);
485
+
486
+ const result = await mockServer.callTool('search_issues', {
487
+ query: 'login error',
488
+ top_n: 1,
489
+ highlight: true,
490
+ });
491
+
492
+ expect(result.isError).toBeUndefined();
493
+ const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
494
+ expect(parsed[0]).toHaveProperty('highlights');
495
+ const highlights = parsed[0]!['highlights'] as Record<string, string>;
496
+ expect(highlights['summary']).toContain('**');
497
+ });
498
+
499
+ it('omits highlights field when no query terms match', async () => {
500
+ const store = makeMockStore({
501
+ items: [{ id: 1, score: 0.9 }],
502
+ });
503
+ vi.mocked(store.getItem).mockResolvedValue({
504
+ id: 1,
505
+ vector: [],
506
+ metadata: { summary: 'Unrelated issue', description: undefined },
507
+ });
508
+ registerSearchTools(mockServer as never, client, store, embedder);
509
+
510
+ const result = await mockServer.callTool('search_issues', {
511
+ query: 'xyzzy',
512
+ top_n: 1,
513
+ highlight: true,
514
+ });
515
+
516
+ const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
517
+ expect(parsed[0]).not.toHaveProperty('highlights');
518
+ });
519
+
520
+ it('does not call store.getItem for highlighting when highlight is false', async () => {
521
+ const store = makeMockStore({ itemCount: 2 });
522
+ registerSearchTools(mockServer as never, client, store, embedder);
523
+
524
+ await mockServer.callTool('search_issues', { query: 'login', top_n: 2 });
525
+
526
+ // getItem should NOT have been called (no date filter, no highlight)
527
+ expect(store.getItem).not.toHaveBeenCalled();
528
+ });
529
+
530
+ it('still returns id and score alongside highlights', async () => {
531
+ const store = makeMockStore({
532
+ items: [{ id: 42, score: 0.85 }],
533
+ });
534
+ vi.mocked(store.getItem).mockResolvedValue({
535
+ id: 42,
536
+ vector: [],
537
+ metadata: { summary: 'Login timeout issue' },
538
+ });
539
+ registerSearchTools(mockServer as never, client, store, embedder);
540
+
541
+ const result = await mockServer.callTool('search_issues', {
542
+ query: 'login',
543
+ top_n: 1,
544
+ highlight: true,
545
+ });
546
+
547
+ const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
548
+ expect(parsed[0]).toHaveProperty('id', 42);
549
+ expect(parsed[0]).toHaveProperty('score');
550
+ expect(parsed[0]).toHaveProperty('highlights');
551
+ });
552
+ });
553
+
554
+ describe('search_issues – highlight: true (with select)', () => {
555
+ it('highlights summary from fetched issue when summary is in select', async () => {
556
+ const store = makeMockStore({ itemCount: 1 });
557
+ registerSearchTools(mockServer as never, client, store, embedder);
558
+
559
+ vi.mocked(fetch).mockResolvedValueOnce(
560
+ makeResponse(200, JSON.stringify({
561
+ issues: [{ id: 1, summary: 'Login error in dashboard', status: { id: 10, name: 'new' } }],
562
+ }))
563
+ );
564
+
565
+ const result = await mockServer.callTool('search_issues', {
566
+ query: 'login error',
567
+ top_n: 1,
568
+ select: 'summary,status',
569
+ highlight: true,
570
+ });
571
+
572
+ expect(result.isError).toBeUndefined();
573
+ const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
574
+ expect(parsed[0]).toHaveProperty('highlights');
575
+ const highlights = parsed[0]!['highlights'] as Record<string, string>;
576
+ expect(highlights['summary']).toContain('**Login**');
577
+ });
578
+
579
+ it('falls back to store metadata for highlighting when API fetch fails', async () => {
580
+ const store = makeMockStore({ items: [{ id: 1, score: 0.9 }] });
581
+ vi.mocked(store.getItem).mockResolvedValue({
582
+ id: 1,
583
+ vector: [],
584
+ metadata: { summary: 'Login crash issue' },
585
+ });
586
+ registerSearchTools(mockServer as never, client, store, embedder);
587
+
588
+ vi.mocked(fetch).mockResolvedValueOnce(makeResponse(500, 'Server Error'));
589
+
590
+ const result = await mockServer.callTool('search_issues', {
591
+ query: 'login',
592
+ top_n: 1,
593
+ select: 'summary',
594
+ highlight: true,
595
+ });
596
+
597
+ // Falls back to {id, score} but we still try to add highlights from store metadata
598
+ const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
599
+ expect(parsed[0]).toHaveProperty('id', 1);
600
+ });
601
+ });
602
+
603
+ describe('search_issues – highlight: true combined with date filter', () => {
604
+ it('returns highlights only for date-filtered results (no select)', async () => {
605
+ const store = makeMockStore({
606
+ items: [
607
+ { id: 1, score: 0.9, updated_at: '2026-03-26T00:00:00Z' }, // passes filter
608
+ { id: 2, score: 0.8, updated_at: '2026-03-20T00:00:00Z' }, // filtered out
609
+ ],
610
+ });
611
+ vi.mocked(store.getItem).mockImplementation(async (id: number) => ({
612
+ id,
613
+ vector: [],
614
+ metadata: {
615
+ summary: id === 1 ? 'Login crash issue' : 'Old login bug',
616
+ updated_at: id === 1 ? '2026-03-26T00:00:00Z' : '2026-03-20T00:00:00Z',
617
+ },
618
+ }));
619
+ registerSearchTools(mockServer as never, client, store, embedder);
620
+
621
+ const result = await mockServer.callTool('search_issues', {
622
+ query: 'login',
623
+ top_n: 10,
624
+ highlight: true,
625
+ updated_after: '2026-03-25T00:00:00Z',
626
+ });
627
+
628
+ const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number }>;
629
+ expect(parsed).toHaveLength(1);
630
+ expect(parsed[0]!.id).toBe(1);
631
+ });
632
+ });