@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.
- package/CHANGELOG.md +31 -0
- package/README.de.md +3 -3
- package/README.md +3 -3
- package/dist/client.js +7 -0
- package/dist/config.js +1 -0
- package/dist/date-filter.js +55 -0
- package/dist/index.js +1 -1
- package/dist/resources/index.js +14 -7
- package/dist/search/highlight.js +63 -0
- package/dist/search/store.js +4 -0
- package/dist/search/tools.js +65 -4
- package/dist/tools/config.js +23 -8
- package/dist/tools/issues.js +123 -18
- package/dist/tools/notes.js +1 -1
- package/docs/cookbook.de.md +64 -7
- package/docs/cookbook.md +64 -7
- package/docs/examples.de.md +12 -0
- package/docs/examples.md +12 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/tests/fixtures/get_issue.json +22 -0
- package/tests/helpers/search-mocks.ts +29 -6
- package/tests/search/highlight.test.ts +129 -0
- package/tests/search/tools.test.ts +258 -0
- package/tests/tools/issues.test.ts +446 -4
- package/tests/utils/date-filter.test.ts +169 -0
|
@@ -12,10 +12,29 @@ export const MOCK_VECTOR = Array(384).fill(0.1) as number[];
|
|
|
12
12
|
// makeMockStore
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
|
|
15
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|