@dpesch/mantisbt-mcp-server 1.0.3 → 1.2.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.
@@ -0,0 +1,110 @@
1
+ {
2
+ "issues": [
3
+ {
4
+ "id": 7857,
5
+ "summary": "Issue summary 1",
6
+ "description": "Issue description 1",
7
+ "project": {
8
+ "id": 54,
9
+ "name": "Project 2"
10
+ },
11
+ "category": {
12
+ "id": 394,
13
+ "name": "Hooks"
14
+ },
15
+ "reporter": {
16
+ "id": 51,
17
+ "name": "user_1",
18
+ "real_name": "User One",
19
+ "email": "user1@example.com"
20
+ },
21
+ "handler": {
22
+ "id": 51,
23
+ "name": "user_1",
24
+ "real_name": "User One",
25
+ "email": "user1@example.com"
26
+ },
27
+ "status": {
28
+ "id": 80,
29
+ "name": "resolved",
30
+ "label": "erledigt",
31
+ "color": "#e8e8e8"
32
+ },
33
+ "resolution": {
34
+ "id": 20,
35
+ "name": "fixed",
36
+ "label": "erledigt"
37
+ },
38
+ "view_state": {
39
+ "id": 10,
40
+ "name": "public",
41
+ "label": "öffentlich"
42
+ },
43
+ "priority": {
44
+ "id": 30,
45
+ "name": "normal",
46
+ "label": "normal"
47
+ },
48
+ "severity": {
49
+ "id": 0,
50
+ "name": "@0@",
51
+ "label": "@0@"
52
+ },
53
+ "reproducibility": {
54
+ "id": 70,
55
+ "name": "have not tried",
56
+ "label": "nicht getestet"
57
+ },
58
+ "sticky": false,
59
+ "created_at": "2026-03-15T22:11:31+01:00",
60
+ "updated_at": "2026-03-15T22:23:08+01:00",
61
+ "history": [
62
+ {
63
+ "created_at": "2026-03-15T22:11:31+01:00",
64
+ "user": {
65
+ "id": 51,
66
+ "name": "user_1",
67
+ "real_name": "User One",
68
+ "email": "user1@example.com"
69
+ },
70
+ "type": {
71
+ "id": 1,
72
+ "name": "issue-new"
73
+ },
74
+ "message": "Neuer Eintrag"
75
+ },
76
+ {
77
+ "created_at": "2026-03-15T22:23:08+01:00",
78
+ "user": {
79
+ "id": 51,
80
+ "name": "user_1",
81
+ "real_name": "User One",
82
+ "email": "user1@example.com"
83
+ },
84
+ "field": {
85
+ "name": "status",
86
+ "label": "Status"
87
+ },
88
+ "type": {
89
+ "id": 0,
90
+ "name": "field-updated"
91
+ },
92
+ "old_value": {
93
+ "id": 50,
94
+ "name": "assigned",
95
+ "label": "zugewiesen",
96
+ "color": "#afbed5"
97
+ },
98
+ "new_value": {
99
+ "id": 80,
100
+ "name": "resolved",
101
+ "label": "erledigt",
102
+ "color": "#e8e8e8"
103
+ },
104
+ "message": "Status",
105
+ "change": "zugewiesen => erledigt"
106
+ }
107
+ ]
108
+ }
109
+ ]
110
+ }
@@ -62,6 +62,40 @@
62
62
  "sticky": false,
63
63
  "created_at": "2026-03-13T09:58:23+01:00",
64
64
  "updated_at": "2026-03-13T11:25:19+01:00"
65
+ },
66
+ {
67
+ "id": 7900,
68
+ "summary": "Issue summary 4 (open)",
69
+ "description": "Issue description 4",
70
+ "project": { "id": 54, "name": "Project 2" },
71
+ "category": { "id": 350, "name": "Konfiguration" },
72
+ "reporter": { "id": 52, "name": "user_2" },
73
+ "handler": {
74
+ "id": 51,
75
+ "name": "user_1",
76
+ "real_name": "User One",
77
+ "email": "user1@example.com"
78
+ },
79
+ "status": { "id": 50, "name": "assigned", "label": "zugewiesen", "color": "#e8e8e8" },
80
+ "priority": { "id": 30, "name": "normal", "label": "normal" },
81
+ "severity": { "id": 210, "name": "Wartung", "label": "Wartung" },
82
+ "sticky": false,
83
+ "created_at": "2026-03-14T10:00:00+01:00",
84
+ "updated_at": "2026-03-14T10:00:00+01:00"
85
+ },
86
+ {
87
+ "id": 7901,
88
+ "summary": "Issue summary 5 (open, new)",
89
+ "description": "Issue description 5",
90
+ "project": { "id": 54, "name": "Project 2" },
91
+ "category": { "id": 350, "name": "Konfiguration" },
92
+ "reporter": { "id": 52, "name": "user_2" },
93
+ "status": { "id": 10, "name": "new", "label": "neu", "color": "#fcbdbd" },
94
+ "priority": { "id": 30, "name": "normal", "label": "normal" },
95
+ "severity": { "id": 210, "name": "Wartung", "label": "Wartung" },
96
+ "sticky": false,
97
+ "created_at": "2026-03-14T11:00:00+01:00",
98
+ "updated_at": "2026-03-14T11:00:00+01:00"
65
99
  }
66
100
  ]
67
101
  }
@@ -1,3 +1,13 @@
1
+ export function makeResponse(status: number, body: string): Response {
2
+ return {
3
+ ok: status >= 200 && status < 300,
4
+ status,
5
+ statusText: `Status ${status}`,
6
+ text: () => Promise.resolve(body),
7
+ headers: { get: (_key: string) => null },
8
+ } as unknown as Response;
9
+ }
10
+
1
11
  // Typ für das Result-Objekt das die Tools zurückgeben
2
12
  export interface ToolResult {
3
13
  content: Array<{ type: string; text: string }>;
@@ -0,0 +1,58 @@
1
+ import { vi } from 'vitest';
2
+ import type { VectorStore, VectorStoreItem } from '../../src/search/store.js';
3
+ import type { Embedder } from '../../src/search/embedder.js';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Shared constants
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export const MOCK_VECTOR = Array(384).fill(0.1) as number[];
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // makeMockStore
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export function makeMockStore(options?: { lastSyncedAt?: string | null; itemCount?: number }): VectorStore {
16
+ const lastSyncedAt = options?.lastSyncedAt ?? null;
17
+ const addedItems: VectorStoreItem[] = [];
18
+ let count = options?.itemCount ?? 0;
19
+
20
+ return {
21
+ add: vi.fn(async (item: VectorStoreItem) => {
22
+ addedItems.push(item);
23
+ count++;
24
+ }),
25
+ addBatch: vi.fn(async (items: VectorStoreItem[]) => {
26
+ for (const item of items) {
27
+ addedItems.push(item);
28
+ }
29
+ count += items.length;
30
+ }),
31
+ search: vi.fn(async (_vec: number[], topN: number) =>
32
+ Array.from({ length: Math.min(topN, count) }, (_, i) => ({
33
+ id: i + 1,
34
+ score: 1 - i * 0.1,
35
+ }))
36
+ ),
37
+ delete: vi.fn(async () => {}),
38
+ count: vi.fn(async () => count),
39
+ clear: vi.fn(async () => {
40
+ addedItems.splice(0);
41
+ count = 0;
42
+ }),
43
+ getLastSyncedAt: vi.fn(async () => lastSyncedAt),
44
+ setLastSyncedAt: vi.fn(async () => {}),
45
+ resetLastSyncedAt: vi.fn(async () => {}),
46
+ };
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // makeMockEmbedder
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export function makeMockEmbedder(): Embedder {
54
+ return {
55
+ embed: vi.fn(async () => MOCK_VECTOR),
56
+ embedBatch: vi.fn(async (texts: string[]) => texts.map(() => MOCK_VECTOR)),
57
+ } as unknown as Embedder;
58
+ }
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { VectraStore } from '../../src/search/store.js';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+
12
+ function randomVector(dim = 384): number[] {
13
+ return Array.from({ length: dim }, () => Math.random() * 2 - 1);
14
+ }
15
+
16
+ function tmpDir(): string {
17
+ return join(tmpdir(), `mantis-search-test-${randomBytes(8).toString('hex')}`);
18
+ }
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Tests
22
+ // ---------------------------------------------------------------------------
23
+
24
+ let store: VectraStore;
25
+ let dir: string;
26
+
27
+ beforeEach(() => {
28
+ dir = tmpDir();
29
+ store = new VectraStore(dir);
30
+ });
31
+
32
+ afterEach(async () => {
33
+ await rm(dir, { recursive: true, force: true });
34
+ });
35
+
36
+ describe('VectraStore.count', () => {
37
+ it('returns 0 on an empty store', async () => {
38
+ expect(await store.count()).toBe(0);
39
+ });
40
+ });
41
+
42
+ describe('VectraStore.add', () => {
43
+ it('increases count after adding an item', async () => {
44
+ await store.add({ id: 1, vector: randomVector(), metadata: { summary: 'First issue' } });
45
+ expect(await store.count()).toBe(1);
46
+ });
47
+
48
+ it('persists across store instances (same dir)', async () => {
49
+ await store.add({ id: 42, vector: randomVector(), metadata: { summary: 'Persistent' } });
50
+
51
+ const store2 = new VectraStore(dir);
52
+ expect(await store2.count()).toBe(1);
53
+ });
54
+
55
+ it('overwrites an existing item with the same id', async () => {
56
+ const vec1 = randomVector();
57
+ const vec2 = randomVector();
58
+ await store.add({ id: 7, vector: vec1, metadata: { summary: 'Original' } });
59
+ await store.add({ id: 7, vector: vec2, metadata: { summary: 'Updated' } });
60
+ expect(await store.count()).toBe(1);
61
+ });
62
+ });
63
+
64
+ describe('VectraStore.search', () => {
65
+ it('returns results sorted by descending score', async () => {
66
+ const queryVec = randomVector();
67
+ // Add items with known vectors; one is identical to the query (score = 1)
68
+ await store.add({ id: 1, vector: [...queryVec], metadata: { summary: 'Exact match' } });
69
+ await store.add({ id: 2, vector: randomVector(), metadata: { summary: 'Random' } });
70
+ await store.add({ id: 3, vector: randomVector(), metadata: { summary: 'Another random' } });
71
+
72
+ const results = await store.search(queryVec, 3);
73
+
74
+ expect(results.length).toBe(3);
75
+ expect(results[0]!.id).toBe(1);
76
+ expect(results[0]!.score).toBeCloseTo(1, 5);
77
+ // Scores are descending
78
+ for (let i = 1; i < results.length; i++) {
79
+ expect(results[i - 1]!.score).toBeGreaterThanOrEqual(results[i]!.score);
80
+ }
81
+ });
82
+
83
+ it('respects the topN limit', async () => {
84
+ for (let i = 1; i <= 5; i++) {
85
+ await store.add({ id: i, vector: randomVector(), metadata: { summary: `Issue ${i}` } });
86
+ }
87
+ const results = await store.search(randomVector(), 3);
88
+ expect(results.length).toBe(3);
89
+ });
90
+
91
+ it('returns empty array on empty store', async () => {
92
+ const results = await store.search(randomVector(), 5);
93
+ expect(results).toEqual([]);
94
+ });
95
+ });
96
+
97
+ describe('VectraStore.delete', () => {
98
+ it('removes an item and decreases count', async () => {
99
+ await store.add({ id: 10, vector: randomVector(), metadata: { summary: 'To delete' } });
100
+ await store.add({ id: 11, vector: randomVector(), metadata: { summary: 'To keep' } });
101
+ await store.delete(10);
102
+ expect(await store.count()).toBe(1);
103
+ });
104
+
105
+ it('does nothing when deleting a non-existent id', async () => {
106
+ await store.add({ id: 5, vector: randomVector(), metadata: { summary: 'Exists' } });
107
+ await store.delete(9999);
108
+ expect(await store.count()).toBe(1);
109
+ });
110
+ });
111
+
112
+ describe('VectraStore.clear', () => {
113
+ it('removes all items', async () => {
114
+ for (let i = 1; i <= 3; i++) {
115
+ await store.add({ id: i, vector: randomVector(), metadata: { summary: `Issue ${i}` } });
116
+ }
117
+ await store.clear();
118
+ expect(await store.count()).toBe(0);
119
+ });
120
+ });
121
+
122
+ describe('VectraStore.getLastSyncedAt / setLastSyncedAt', () => {
123
+ it('returns null initially', async () => {
124
+ expect(await store.getLastSyncedAt()).toBeNull();
125
+ });
126
+
127
+ it('returns the value after setting it', async () => {
128
+ const ts = '2024-01-15T10:00:00.000Z';
129
+ await store.setLastSyncedAt(ts);
130
+ expect(await store.getLastSyncedAt()).toBe(ts);
131
+ });
132
+
133
+ it('persists the value across instances', async () => {
134
+ const ts = '2024-06-01T12:34:56.000Z';
135
+ await store.setLastSyncedAt(ts);
136
+
137
+ const store2 = new VectraStore(dir);
138
+ expect(await store2.getLastSyncedAt()).toBe(ts);
139
+ });
140
+ });
141
+
142
+ describe('VectraStore.resetLastSyncedAt', () => {
143
+ it('clears the lastSyncedAt value', async () => {
144
+ await store.setLastSyncedAt('2024-01-01T00:00:00.000Z');
145
+ await store.resetLastSyncedAt();
146
+ expect(await store.getLastSyncedAt()).toBeNull();
147
+ });
148
+ });
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { MantisClient } from '../../src/client.js';
3
+ import { SearchSyncService } from '../../src/search/sync.js';
4
+ import { makeMockStore, makeMockEmbedder } from '../helpers/search-mocks.js';
5
+ import { makeResponse } from '../helpers/mock-server.js';
6
+ import type { Embedder } from '../../src/search/embedder.js';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Fixtures
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const ISSUE_FIXTURE = [
13
+ { id: 101, summary: 'Login fails on mobile', description: 'Steps to reproduce...', updated_at: '2024-03-10T08:00:00Z' },
14
+ { id: 102, summary: 'Dashboard loads slowly', description: 'Takes over 10 seconds', updated_at: '2024-03-09T14:00:00Z' },
15
+ { id: 103, description: 'No summary here', updated_at: '2024-03-08T10:00:00Z' }, // no summary → skipped
16
+ ];
17
+
18
+ const LIST_ISSUES_RESPONSE = {
19
+ issues: ISSUE_FIXTURE,
20
+ total_count: ISSUE_FIXTURE.length,
21
+ };
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Setup
25
+ // ---------------------------------------------------------------------------
26
+
27
+ let client: MantisClient;
28
+ let embedder: Embedder;
29
+
30
+ beforeEach(() => {
31
+ client = new MantisClient('https://mantis.example.com', 'test-token');
32
+ embedder = makeMockEmbedder();
33
+ vi.stubGlobal('fetch', vi.fn());
34
+ });
35
+
36
+ afterEach(() => {
37
+ vi.unstubAllGlobals();
38
+ vi.clearAllMocks();
39
+ });
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // sync without previous state
43
+ // ---------------------------------------------------------------------------
44
+
45
+ describe('SearchSyncService.sync – no previous state', () => {
46
+ it('indexes all issues with summaries and skips those without', async () => {
47
+ const store = makeMockStore({ lastSyncedAt: null });
48
+ vi.mocked(fetch).mockResolvedValue(
49
+ makeResponse(200, JSON.stringify(LIST_ISSUES_RESPONSE))
50
+ );
51
+
52
+ const service = new SearchSyncService(client, store, embedder);
53
+ const result = await service.sync();
54
+
55
+ // 2 issues have summaries, 1 does not
56
+ expect(result.indexed).toBe(2);
57
+ expect(result.skipped).toBe(1);
58
+ expect(store.addBatch).toHaveBeenCalledTimes(1);
59
+ expect(store.setLastSyncedAt).toHaveBeenCalledTimes(1);
60
+ });
61
+
62
+ it('calls the API without updated_after when no lastSyncedAt', async () => {
63
+ const store = makeMockStore({ lastSyncedAt: null });
64
+ vi.mocked(fetch).mockResolvedValue(
65
+ makeResponse(200, JSON.stringify(LIST_ISSUES_RESPONSE))
66
+ );
67
+
68
+ const service = new SearchSyncService(client, store, embedder);
69
+ await service.sync();
70
+
71
+ const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
72
+ const url = new URL(calledUrl);
73
+ expect(url.searchParams.has('updated_after')).toBe(false);
74
+ });
75
+ });
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // sync with lastSyncedAt (incremental)
79
+ // ---------------------------------------------------------------------------
80
+
81
+ describe('SearchSyncService.sync – incremental (with lastSyncedAt)', () => {
82
+ it('passes updated_after to the API', async () => {
83
+ const lastSync = '2024-03-09T00:00:00.000Z';
84
+ const store = makeMockStore({ lastSyncedAt: lastSync });
85
+ const incrementalResponse = {
86
+ issues: [ISSUE_FIXTURE[0]!], // only one newer issue
87
+ total_count: 1,
88
+ };
89
+ vi.mocked(fetch).mockResolvedValue(
90
+ makeResponse(200, JSON.stringify(incrementalResponse))
91
+ );
92
+
93
+ const service = new SearchSyncService(client, store, embedder);
94
+ await service.sync();
95
+
96
+ const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
97
+ const url = new URL(calledUrl);
98
+ expect(url.searchParams.get('updated_after')).toBe(lastSync);
99
+ expect(store.addBatch).toHaveBeenCalledTimes(1);
100
+ });
101
+ });
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Issues without summary are skipped
105
+ // ---------------------------------------------------------------------------
106
+
107
+ describe('SearchSyncService.sync – skip issues without summary', () => {
108
+ it('returns correct skipped count', async () => {
109
+ const store = makeMockStore({ lastSyncedAt: null });
110
+ const onlyNoSummaryResponse = {
111
+ issues: [{ id: 200, description: 'No summary', updated_at: '2024-01-01T00:00:00Z' }],
112
+ total_count: 1,
113
+ };
114
+ vi.mocked(fetch).mockResolvedValue(
115
+ makeResponse(200, JSON.stringify(onlyNoSummaryResponse))
116
+ );
117
+
118
+ const service = new SearchSyncService(client, store, embedder);
119
+ const result = await service.sync();
120
+
121
+ expect(result.indexed).toBe(0);
122
+ expect(result.skipped).toBe(1);
123
+ expect(store.addBatch).not.toHaveBeenCalled();
124
+ });
125
+ });
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // project_id is forwarded
129
+ // ---------------------------------------------------------------------------
130
+
131
+ describe('SearchSyncService.sync – project_id', () => {
132
+ it('passes project_id to the API', async () => {
133
+ const store = makeMockStore({ lastSyncedAt: null });
134
+ vi.mocked(fetch).mockResolvedValue(
135
+ makeResponse(200, JSON.stringify({ issues: [], total_count: 0 }))
136
+ );
137
+
138
+ const service = new SearchSyncService(client, store, embedder);
139
+ await service.sync(7);
140
+
141
+ const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
142
+ const url = new URL(calledUrl);
143
+ expect(url.searchParams.get('project_id')).toBe('7');
144
+ });
145
+ });
@@ -0,0 +1,160 @@
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
+ });