@dpesch/mantisbt-mcp-server 1.10.3 → 1.11.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.de.md +11 -8
  3. package/README.md +11 -8
  4. package/dist/config.js +13 -0
  5. package/dist/index.js +12 -12
  6. package/dist/tools/files.js +11 -2
  7. package/dist/tools/issues.js +47 -15
  8. package/dist/tools/notes.js +15 -10
  9. package/docs/cookbook.de.md +67 -0
  10. package/docs/cookbook.md +67 -0
  11. package/docs/examples.de.md +8 -0
  12. package/docs/examples.md +8 -0
  13. package/package.json +1 -1
  14. package/server.json +2 -2
  15. package/.gitea/PULL_REQUEST_TEMPLATE.md +0 -17
  16. package/.gitea/workflows/ci.yml +0 -95
  17. package/.github/workflows/ci.yml +0 -32
  18. package/scripts/hooks/pre-push.mjs +0 -220
  19. package/scripts/init.mjs +0 -95
  20. package/scripts/record-fixtures.ts +0 -160
  21. package/tests/cache.test.ts +0 -265
  22. package/tests/client.test.ts +0 -372
  23. package/tests/config.test.ts +0 -263
  24. package/tests/fixtures/get_current_user.json +0 -40
  25. package/tests/fixtures/get_issue.json +0 -133
  26. package/tests/fixtures/get_issue_enums.json +0 -67
  27. package/tests/fixtures/get_issue_fields_sample.json +0 -76
  28. package/tests/fixtures/get_project_categories.json +0 -157
  29. package/tests/fixtures/get_project_versions.json +0 -3
  30. package/tests/fixtures/get_project_versions_with_data.json +0 -52
  31. package/tests/fixtures/list_issues.json +0 -95
  32. package/tests/fixtures/list_projects.json +0 -65
  33. package/tests/helpers/mock-server.ts +0 -166
  34. package/tests/helpers/search-mocks.ts +0 -84
  35. package/tests/prompts/prompts.test.ts +0 -242
  36. package/tests/resources/resources.test.ts +0 -309
  37. package/tests/search/embedder.test.ts +0 -81
  38. package/tests/search/highlight.test.ts +0 -129
  39. package/tests/search/store.test.ts +0 -193
  40. package/tests/search/sync.test.ts +0 -249
  41. package/tests/search/tools.test.ts +0 -661
  42. package/tests/tools/config.test.ts +0 -212
  43. package/tests/tools/files.test.ts +0 -343
  44. package/tests/tools/issues.test.ts +0 -1180
  45. package/tests/tools/metadata.test.ts +0 -509
  46. package/tests/tools/monitors.test.ts +0 -101
  47. package/tests/tools/projects.test.ts +0 -338
  48. package/tests/tools/relationships.test.ts +0 -177
  49. package/tests/tools/string-coercion.test.ts +0 -317
  50. package/tests/tools/users.test.ts +0 -62
  51. package/tests/utils/date-filter.test.ts +0 -169
  52. package/tests/version-hint.test.ts +0 -230
  53. package/tsconfig.build.json +0 -8
  54. package/vitest.config.ts +0 -8
@@ -1,193 +0,0 @@
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
- });
149
-
150
- describe('VectraStore.addBatch', () => {
151
- it('increases in-memory count without persisting to disk', async () => {
152
- await store.addBatch([
153
- { id: 1, vector: randomVector(), metadata: { summary: 'A' } },
154
- { id: 2, vector: randomVector(), metadata: { summary: 'B' } },
155
- ]);
156
- expect(await store.count()).toBe(2);
157
-
158
- // A new instance reading the same dir must not see the items yet
159
- const store2 = new VectraStore(dir);
160
- expect(await store2.count()).toBe(0);
161
- });
162
-
163
- it('persists items after flush()', async () => {
164
- await store.addBatch([
165
- { id: 10, vector: randomVector(), metadata: { summary: 'X' } },
166
- { id: 11, vector: randomVector(), metadata: { summary: 'Y' } },
167
- ]);
168
- await store.flush();
169
-
170
- const store2 = new VectraStore(dir);
171
- expect(await store2.count()).toBe(2);
172
- });
173
-
174
- it('accumulates items across multiple addBatch calls before flush', async () => {
175
- await store.addBatch([{ id: 1, vector: randomVector(), metadata: { summary: 'First' } }]);
176
- await store.addBatch([{ id: 2, vector: randomVector(), metadata: { summary: 'Second' } }]);
177
- await store.flush();
178
-
179
- const store2 = new VectraStore(dir);
180
- expect(await store2.count()).toBe(2);
181
- });
182
- });
183
-
184
- describe('VectraStore.flush (atomic write)', () => {
185
- it('leaves no .tmp file after a successful flush', async () => {
186
- const { readdir } = await import('node:fs/promises');
187
- await store.addBatch([{ id: 1, vector: randomVector(), metadata: { summary: 'Test' } }]);
188
- await store.flush();
189
-
190
- const files = await readdir(join(dir, 'vectra'));
191
- expect(files.some(f => f.endsWith('.tmp'))).toBe(false);
192
- });
193
- });
@@ -1,249 +0,0 @@
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
- });
146
-
147
- // ---------------------------------------------------------------------------
148
- // flush / checkpoint behaviour
149
- // ---------------------------------------------------------------------------
150
-
151
- describe('SearchSyncService.sync – flush and checkpoint', () => {
152
- function makeIssues(count: number) {
153
- return Array.from({ length: count }, (_, i) => ({
154
- id: i + 1,
155
- summary: `Issue ${i + 1}`,
156
- description: `Description ${i + 1}`,
157
- updated_at: '2024-03-10T08:00:00Z',
158
- }));
159
- }
160
-
161
- it('calls flush exactly once when fewer than 100 issues are indexed', async () => {
162
- const store = makeMockStore({ lastSyncedAt: null });
163
- vi.mocked(fetch).mockResolvedValue(
164
- makeResponse(200, JSON.stringify({ issues: makeIssues(50), total_count: 50 }))
165
- );
166
-
167
- const service = new SearchSyncService(client, store, embedder);
168
- await service.sync();
169
-
170
- // Only the final flush, no checkpoint
171
- expect(store.flush).toHaveBeenCalledTimes(1);
172
- });
173
-
174
- it('calls flush exactly once when exactly 100 issues are indexed (checkpoint covers all, no redundant final)', async () => {
175
- const store = makeMockStore({ lastSyncedAt: null });
176
- vi.mocked(fetch).mockResolvedValue(
177
- makeResponse(200, JSON.stringify({ issues: makeIssues(100), total_count: 100 }))
178
- );
179
-
180
- const service = new SearchSyncService(client, store, embedder);
181
- await service.sync();
182
-
183
- // Checkpoint at 100 covers all items; indexedSinceCheckpoint resets to 0 → no redundant final flush
184
- expect(store.flush).toHaveBeenCalledTimes(1);
185
- });
186
-
187
- it('calls flush twice when 110 issues are indexed (checkpoint at 100 + final for remaining 10)', async () => {
188
- const store = makeMockStore({ lastSyncedAt: null });
189
- vi.mocked(fetch).mockResolvedValue(
190
- makeResponse(200, JSON.stringify({ issues: makeIssues(110), total_count: 110 }))
191
- );
192
-
193
- const service = new SearchSyncService(client, store, embedder);
194
- await service.sync();
195
-
196
- // Checkpoint at 100, then final flush for remaining 10
197
- expect(store.flush).toHaveBeenCalledTimes(2);
198
- });
199
- });
200
-
201
- // ---------------------------------------------------------------------------
202
- // total_count persistence (regression: MantisBT installations without total_count)
203
- // ---------------------------------------------------------------------------
204
-
205
- describe('SearchSyncService.sync – setLastKnownTotal persistence', () => {
206
- it('persists total_count from API response', async () => {
207
- const store = makeMockStore({ lastSyncedAt: null });
208
- vi.mocked(fetch).mockResolvedValue(
209
- makeResponse(200, JSON.stringify({ issues: ISSUE_FIXTURE, total_count: 42 }))
210
- );
211
-
212
- const service = new SearchSyncService(client, store, embedder);
213
- const result = await service.sync();
214
-
215
- expect(store.setLastKnownTotal).toHaveBeenCalledWith(42);
216
- expect(result.total).toBe(42);
217
- });
218
-
219
- it('uses indexed+skipped as fallback when API omits total_count (full rebuild)', async () => {
220
- // Regression test: MantisBT installations that do not return total_count
221
- const store = makeMockStore({ lastSyncedAt: null });
222
- vi.mocked(fetch).mockResolvedValue(
223
- makeResponse(200, JSON.stringify({ issues: ISSUE_FIXTURE })) // no total_count field
224
- );
225
-
226
- const service = new SearchSyncService(client, store, embedder);
227
- const result = await service.sync();
228
-
229
- // ISSUE_FIXTURE: 2 indexed (have summary), 1 skipped (no summary) → total = 3
230
- expect(result.total).toBe(3);
231
- expect(store.setLastKnownTotal).toHaveBeenCalledWith(3);
232
- });
233
-
234
- it('falls back to store.count() for incremental sync when API omits total_count', async () => {
235
- // MantisBT installations without total_count: incremental sync uses the current store
236
- // size as best available estimate so the status tool never shows "total unknown".
237
- const store = makeMockStore({ lastSyncedAt: '2024-03-01T00:00:00.000Z', itemCount: 100 });
238
- vi.mocked(fetch).mockResolvedValue(
239
- makeResponse(200, JSON.stringify({ issues: [ISSUE_FIXTURE[0]!] })) // no total_count, partial result
240
- );
241
-
242
- const service = new SearchSyncService(client, store, embedder);
243
- const result = await service.sync();
244
-
245
- // store.count() returns 101 after adding the 1 new item (mock increments count on addBatch)
246
- expect(result.total).toBeGreaterThan(0);
247
- expect(store.setLastKnownTotal).toHaveBeenCalled();
248
- });
249
- });