@dpesch/mantisbt-mcp-server 1.14.0 → 1.14.1
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 +12 -0
- package/README.de.md +1 -1
- package/README.md +1 -1
- package/dist/search/embedder.js +49 -14
- package/dist/search/index.js +9 -4
- package/dist/search/store.js +30 -7
- package/dist/search/sync-lock.js +66 -0
- package/dist/search/sync.js +67 -9
- package/dist/search/tools.js +3 -7
- package/docs/cookbook.de.md +6 -4
- package/docs/cookbook.md +6 -4
- package/package.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,18 @@ This project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
+
## [1.14.1] – 2026-10-24
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Semantic search: the background sync on server start re-fetched and re-embedded **every** issue instead of only the changed ones. The MantisBT REST API ignores the `updated_after` filter the sync relied on, so each start of each client session caused minutes of full CPU load and rewrote the whole index file. The sync now filters by `updated_at` itself and stops paging at the first unchanged issue; issues whose indexed version is already current are not re-embedded. With an up-to-date index a start now fetches a single page.
|
|
19
|
+
- Semantic search: only one server process syncs the shared index at a time (lock file `sync.lock` in the search directory, stale locks from crashed processes are taken over). Other processes skip the startup sync; `rebuild_search_index` returns an error while another sync is running. Previously concurrent syncs overwrote each other's `index.json`.
|
|
20
|
+
- Semantic search: a server process now reloads the index when another process has rewritten it, instead of writing its outdated copy back on the next flush.
|
|
21
|
+
- Semantic search: the embedding model is unloaded after 10 minutes without embedding and reloaded on the next use (a few seconds). Previously each server process kept it loaded until exit — about 0.9 GB RAM per client session once it had searched or indexed anything. An idle process now drops back to roughly the size of the loaded index.
|
|
22
|
+
- Semantic search: `last_synced_at` is now taken from the newest `updated_at` reported by the server instead of the local clock, so a client clock running ahead of the server can no longer skip changes. A sync limited to `project_id` no longer advances it, so changes in other projects are not lost.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
14
26
|
## [1.14.0] – 2026-09-27
|
|
15
27
|
|
|
16
28
|
### Fixed
|
package/README.de.md
CHANGED
|
@@ -156,7 +156,7 @@ Statt einfachem Keyword-Matching versteht die semantische Suche die *Bedeutung*
|
|
|
156
156
|
- *„Performance-Probleme auf der Checkout-Seite"* – liefert verwandte Meldungen unabhängig von der verwendeten Terminologie
|
|
157
157
|
- *„doppelte Einträge in der Rechnungsliste"* – erkennt auch Issues, die als „zweifach angezeigt", „dupliziert" o.ä. beschrieben sind
|
|
158
158
|
|
|
159
|
-
Das Embedding-Modell (~80 MB) läuft vollständig **offline** – kein OpenAI-Key, keine externe API. Es wird beim ersten Start einmalig heruntergeladen und lokal gecacht. Issues werden bei jedem Serverstart inkrementell indexiert (nur neue und geänderte Issues werden neu verarbeitet).
|
|
159
|
+
Das Embedding-Modell (~80 MB) läuft vollständig **offline** – kein OpenAI-Key, keine externe API. Es wird beim ersten Start einmalig heruntergeladen und lokal gecacht. Issues werden bei jedem Serverstart inkrementell indexiert (nur neue und geänderte Issues werden neu verarbeitet). Starten mehrere Client-Sitzungen gleichzeitig einen Server, synchronisiert nur einer davon den gemeinsamen Index. Das Modell wird bei der ersten Nutzung geladen und nach 10 Minuten ohne Einbetten wieder entladen, damit ruhende Sitzungen seinen Speicher (~0,9 GB pro Serverprozess) nicht dauerhaft belegen.
|
|
160
160
|
|
|
161
161
|
Aktivierung mit `MANTIS_SEARCH_ENABLED=true`.
|
|
162
162
|
|
package/README.md
CHANGED
|
@@ -156,7 +156,7 @@ Instead of exact keyword matching, semantic search understands the *meaning* beh
|
|
|
156
156
|
- *"performance problems on the checkout page"* — surfaces related reports regardless of the exact terminology used
|
|
157
157
|
- *"duplicate entries in the invoice list"* — catches issues described as "shown twice", "double records", etc.
|
|
158
158
|
|
|
159
|
-
The embedding model (~80 MB) runs entirely **offline** — no OpenAI key, no external API. It is downloaded once on first start and cached locally. Issues are indexed incrementally on every server start (only new and updated issues are re-indexed).
|
|
159
|
+
The embedding model (~80 MB) runs entirely **offline** — no OpenAI key, no external API. It is downloaded once on first start and cached locally. Issues are indexed incrementally on every server start (only new and updated issues are re-indexed). When several client sessions start a server at the same time, only one of them syncs the shared index. The model is loaded on first use and unloaded again after 10 minutes without embedding, so idle sessions do not hold on to its memory (~0.9 GB per server process).
|
|
160
160
|
|
|
161
161
|
Activate with `MANTIS_SEARCH_ENABLED=true`.
|
|
162
162
|
|
package/dist/search/embedder.js
CHANGED
|
@@ -6,17 +6,51 @@
|
|
|
6
6
|
// @huggingface/transformers is an optional dependency. The import is
|
|
7
7
|
// guarded with a try/catch so the server starts without it.
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
|
+
// A loaded model takes ~0.9 GB RAM, and every client session runs its own
|
|
10
|
+
// server process. An idle process releases the model; the next use reloads it.
|
|
11
|
+
const IDLE_UNLOAD_MS = 10 * 60 * 1000;
|
|
9
12
|
export class Embedder {
|
|
10
13
|
modelName;
|
|
11
14
|
numThreads;
|
|
12
15
|
pipe = null;
|
|
16
|
+
running = 0;
|
|
17
|
+
idleTimer = null;
|
|
13
18
|
constructor(modelName, numThreads = 1) {
|
|
14
19
|
this.modelName = modelName;
|
|
15
20
|
this.numThreads = numThreads;
|
|
16
21
|
}
|
|
22
|
+
async withPipeline(fn) {
|
|
23
|
+
this.running++;
|
|
24
|
+
if (this.idleTimer) {
|
|
25
|
+
clearTimeout(this.idleTimer);
|
|
26
|
+
this.idleTimer = null;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
if (!this.pipe) {
|
|
30
|
+
const loading = this.load();
|
|
31
|
+
// Never keep a failed load around — the next call retries.
|
|
32
|
+
loading.catch(() => { if (this.pipe === loading)
|
|
33
|
+
this.pipe = null; });
|
|
34
|
+
this.pipe = loading;
|
|
35
|
+
}
|
|
36
|
+
return await fn(await this.pipe);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
// The timer only runs while nothing is embedding; unref() keeps it from
|
|
40
|
+
// holding the process alive.
|
|
41
|
+
if (--this.running === 0 && this.pipe) {
|
|
42
|
+
this.idleTimer = setTimeout(() => void this.unload(), IDLE_UNLOAD_MS).unref();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async unload() {
|
|
47
|
+
const pipe = this.pipe;
|
|
48
|
+
this.idleTimer = null;
|
|
49
|
+
this.pipe = null;
|
|
50
|
+
process.stderr.write(`[mantisbt-search] Unloading idle embedding model ${this.modelName}\n`);
|
|
51
|
+
await (await pipe).dispose?.();
|
|
52
|
+
}
|
|
17
53
|
async load() {
|
|
18
|
-
if (this.pipe)
|
|
19
|
-
return this.pipe;
|
|
20
54
|
process.stderr.write(`[mantisbt-search] Loading embedding model ${this.modelName} (threads: ${this.numThreads})...\n`);
|
|
21
55
|
let transformers;
|
|
22
56
|
try {
|
|
@@ -27,29 +61,30 @@ export class Embedder {
|
|
|
27
61
|
const msg = err instanceof Error ? err.message : String(err);
|
|
28
62
|
throw new Error(`Failed to load @huggingface/transformers: ${msg}`);
|
|
29
63
|
}
|
|
30
|
-
|
|
64
|
+
return transformers.pipeline('feature-extraction', this.modelName, {
|
|
31
65
|
session_options: {
|
|
32
66
|
intra_op_num_threads: this.numThreads,
|
|
33
67
|
inter_op_num_threads: 1, // Transformer graphs are sequential — no benefit from inter-op parallelism
|
|
34
68
|
},
|
|
35
69
|
});
|
|
36
|
-
return this.pipe;
|
|
37
70
|
}
|
|
38
71
|
async embed(text) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
72
|
+
return this.withPipeline(async (extractor) => {
|
|
73
|
+
const output = await extractor(text, { pooling: 'mean', normalize: true });
|
|
74
|
+
return extractVector(output);
|
|
75
|
+
});
|
|
42
76
|
}
|
|
43
77
|
async embedBatch(texts) {
|
|
44
78
|
if (texts.length === 0)
|
|
45
79
|
return [];
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
80
|
+
return this.withPipeline(async (extractor) => {
|
|
81
|
+
const output = await extractor(texts, { pooling: 'mean', normalize: true });
|
|
82
|
+
if (Array.isArray(output)) {
|
|
83
|
+
return output.map(extractVector);
|
|
84
|
+
}
|
|
85
|
+
// Some versions return a single tensor for batch input
|
|
86
|
+
return extractBatchVectors(output, texts.length);
|
|
87
|
+
});
|
|
53
88
|
}
|
|
54
89
|
}
|
|
55
90
|
// ---------------------------------------------------------------------------
|
package/dist/search/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createVectorStore } from './store.js';
|
|
2
2
|
import { Embedder } from './embedder.js';
|
|
3
3
|
import { registerSearchTools } from './tools.js';
|
|
4
|
-
import { SearchSyncService } from './sync.js';
|
|
4
|
+
import { SearchSyncService, SyncLockedError } from './sync.js';
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
// initializeSearchModule
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
@@ -10,16 +10,21 @@ export async function initializeSearchModule(server, client, config) {
|
|
|
10
10
|
return;
|
|
11
11
|
const store = createVectorStore(config.backend, config.dir);
|
|
12
12
|
const embedder = new Embedder(config.modelName, config.numThreads);
|
|
13
|
-
registerSearchTools(server, client, store, embedder);
|
|
13
|
+
registerSearchTools(server, client, store, embedder, config.dir);
|
|
14
14
|
// Pre-initialize lastKnownTotal so get_search_index_status shows a value
|
|
15
15
|
// immediately on startup, even while the background sync is still running.
|
|
16
16
|
const [startupCount, startupTotal] = await Promise.all([store.count(), store.getLastKnownTotal()]);
|
|
17
17
|
if (startupTotal === null && startupCount > 0) {
|
|
18
18
|
await store.setLastKnownTotal(startupCount);
|
|
19
19
|
}
|
|
20
|
-
// Non-blocking background sync on startup
|
|
21
|
-
|
|
20
|
+
// Non-blocking background sync on startup. If another server process (another
|
|
21
|
+
// client session) is already syncing the shared index, this one skips it.
|
|
22
|
+
const syncService = new SearchSyncService(client, store, embedder, config.dir);
|
|
22
23
|
syncService.sync().catch((err) => {
|
|
24
|
+
if (err instanceof SyncLockedError) {
|
|
25
|
+
console.error('[mantisbt-search] another process is syncing the index, skipping startup sync');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
23
28
|
console.error('[mantisbt-search] sync error:', err instanceof Error ? err.message : String(err));
|
|
24
29
|
});
|
|
25
30
|
}
|
package/dist/search/store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, writeFile, rename, mkdir, unlink } from 'node:fs/promises';
|
|
1
|
+
import { readFile, writeFile, rename, mkdir, unlink, stat } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
// VectraStore
|
|
@@ -10,19 +10,39 @@ export class VectraStore {
|
|
|
10
10
|
lastTotalFile;
|
|
11
11
|
items = new Map();
|
|
12
12
|
loaded = false;
|
|
13
|
+
// Version of index.json the in-memory items correspond to (null: no file).
|
|
14
|
+
// Every client session runs its own server process on the same index, so
|
|
15
|
+
// another process may have rewritten the file since it was loaded.
|
|
16
|
+
loadedVersion = null;
|
|
17
|
+
// Unflushed addBatch() changes — never discard them by reloading.
|
|
18
|
+
dirty = false;
|
|
13
19
|
constructor(dir) {
|
|
14
20
|
this.dir = dir;
|
|
15
21
|
this.vectraDir = join(dir, 'vectra');
|
|
16
22
|
this.lastSyncFile = join(dir, 'last_sync.txt');
|
|
17
23
|
this.lastTotalFile = join(dir, 'last_total.txt');
|
|
18
24
|
}
|
|
25
|
+
get indexFile() {
|
|
26
|
+
return join(this.vectraDir, 'index.json');
|
|
27
|
+
}
|
|
28
|
+
async fileVersion() {
|
|
29
|
+
try {
|
|
30
|
+
const { mtimeMs, size } = await stat(this.indexFile);
|
|
31
|
+
return `${mtimeMs}:${size}`;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
19
37
|
async ensureLoaded() {
|
|
20
|
-
if (this.loaded)
|
|
38
|
+
if (this.loaded && this.dirty)
|
|
39
|
+
return;
|
|
40
|
+
const version = await this.fileVersion();
|
|
41
|
+
if (this.loaded && version === this.loadedVersion)
|
|
21
42
|
return;
|
|
22
43
|
await mkdir(this.vectraDir, { recursive: true });
|
|
23
|
-
const indexFile = join(this.vectraDir, 'index.json');
|
|
24
44
|
try {
|
|
25
|
-
const raw = await readFile(indexFile, 'utf-8');
|
|
45
|
+
const raw = await readFile(this.indexFile, 'utf-8');
|
|
26
46
|
const parsed = JSON.parse(raw);
|
|
27
47
|
this.items = new Map(parsed.map(item => [item.id, item]));
|
|
28
48
|
}
|
|
@@ -31,13 +51,15 @@ export class VectraStore {
|
|
|
31
51
|
this.items = new Map();
|
|
32
52
|
}
|
|
33
53
|
this.loaded = true;
|
|
54
|
+
this.loadedVersion = version;
|
|
34
55
|
}
|
|
35
56
|
async persist() {
|
|
36
|
-
const
|
|
37
|
-
const tmpFile = indexFile + '.tmp';
|
|
57
|
+
const tmpFile = this.indexFile + '.tmp';
|
|
38
58
|
const data = JSON.stringify([...this.items.values()]);
|
|
39
59
|
await writeFile(tmpFile, data, 'utf-8');
|
|
40
|
-
await rename(tmpFile, indexFile);
|
|
60
|
+
await rename(tmpFile, this.indexFile);
|
|
61
|
+
this.loadedVersion = await this.fileVersion();
|
|
62
|
+
this.dirty = false;
|
|
41
63
|
}
|
|
42
64
|
async add(item) {
|
|
43
65
|
await this.ensureLoaded();
|
|
@@ -49,6 +71,7 @@ export class VectraStore {
|
|
|
49
71
|
for (const item of items) {
|
|
50
72
|
this.items.set(item.id, item);
|
|
51
73
|
}
|
|
74
|
+
this.dirty = items.length > 0 || this.dirty;
|
|
52
75
|
// No persist() here — call flush() once after all batches are processed.
|
|
53
76
|
}
|
|
54
77
|
async flush() {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { open, readFile, unlink, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Sync lock
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Every MCP client session starts its own server process, and all of them share
|
|
7
|
+
// one index directory. The lock makes sure only one process syncs at a time —
|
|
8
|
+
// concurrent syncs would overwrite each other's index.json.
|
|
9
|
+
const LOCK_FILE = 'sync.lock';
|
|
10
|
+
// Fallback for a lock whose PID was reused by an unrelated process.
|
|
11
|
+
const STALE_AFTER_MS = 2 * 60 * 60 * 1000;
|
|
12
|
+
function isProcessAlive(pid) {
|
|
13
|
+
try {
|
|
14
|
+
process.kill(pid, 0);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
// EPERM: the process exists but belongs to another user.
|
|
19
|
+
return err.code === 'EPERM';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function isStale(lockFile) {
|
|
23
|
+
try {
|
|
24
|
+
const content = JSON.parse(await readFile(lockFile, 'utf-8'));
|
|
25
|
+
const age = Date.now() - new Date(content.started_at).getTime();
|
|
26
|
+
return !isProcessAlive(content.pid) || Number.isNaN(age) || age >= STALE_AFTER_MS;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Unreadable, half-written or already removed — safe to (re)try taking it.
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Tries to take the sync lock in `dir`. Returns a release function, or null if
|
|
35
|
+
* another live process currently holds the lock. Stale locks are taken over.
|
|
36
|
+
*/
|
|
37
|
+
export async function acquireSyncLock(dir) {
|
|
38
|
+
await mkdir(dir, { recursive: true });
|
|
39
|
+
const lockFile = join(dir, LOCK_FILE);
|
|
40
|
+
const content = { pid: process.pid, started_at: new Date().toISOString() };
|
|
41
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
42
|
+
try {
|
|
43
|
+
const handle = await open(lockFile, 'wx');
|
|
44
|
+
await handle.writeFile(JSON.stringify(content), 'utf-8');
|
|
45
|
+
await handle.close();
|
|
46
|
+
return () => unlinkIfExists(lockFile);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (err.code !== 'EEXIST')
|
|
50
|
+
throw err;
|
|
51
|
+
if (!(await isStale(lockFile)))
|
|
52
|
+
return null;
|
|
53
|
+
await unlinkIfExists(lockFile);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
async function unlinkIfExists(file) {
|
|
59
|
+
try {
|
|
60
|
+
await unlink(file);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (err.code !== 'ENOENT')
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/dist/search/sync.js
CHANGED
|
@@ -1,19 +1,50 @@
|
|
|
1
|
+
import { acquireSyncLock } from './sync-lock.js';
|
|
1
2
|
// ---------------------------------------------------------------------------
|
|
2
3
|
// SearchSyncService
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
5
|
const PAGE_SIZE = 50;
|
|
5
6
|
const EMBED_BATCH_SIZE = 10;
|
|
6
7
|
const CHECKPOINT_INTERVAL = 100; // flush to disk every N indexed issues
|
|
8
|
+
export class SyncLockedError extends Error {
|
|
9
|
+
constructor() {
|
|
10
|
+
super('A search index sync is already running — try again once it has finished');
|
|
11
|
+
this.name = 'SyncLockedError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
7
14
|
export class SearchSyncService {
|
|
8
15
|
client;
|
|
9
16
|
store;
|
|
10
17
|
embedder;
|
|
11
|
-
|
|
18
|
+
lockDir;
|
|
19
|
+
constructor(client, store, embedder, lockDir) {
|
|
12
20
|
this.client = client;
|
|
13
21
|
this.store = store;
|
|
14
22
|
this.embedder = embedder;
|
|
23
|
+
this.lockDir = lockDir;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Indexes all issues changed since the last sync. With `full`, the index is
|
|
27
|
+
* cleared first — inside the lock, so a running sync of another process is
|
|
28
|
+
* never wiped underneath it.
|
|
29
|
+
*/
|
|
30
|
+
async sync(projectId, { full = false } = {}) {
|
|
31
|
+
if (this.lockDir === undefined)
|
|
32
|
+
return this.syncUnlocked(projectId, full);
|
|
33
|
+
const release = await acquireSyncLock(this.lockDir);
|
|
34
|
+
if (!release)
|
|
35
|
+
throw new SyncLockedError();
|
|
36
|
+
try {
|
|
37
|
+
return await this.syncUnlocked(projectId, full);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
await release();
|
|
41
|
+
}
|
|
15
42
|
}
|
|
16
|
-
async
|
|
43
|
+
async syncUnlocked(projectId, full) {
|
|
44
|
+
if (full) {
|
|
45
|
+
await this.store.clear();
|
|
46
|
+
await this.store.resetLastSyncedAt();
|
|
47
|
+
}
|
|
17
48
|
const lastSyncedAt = await this.store.getLastSyncedAt();
|
|
18
49
|
const { issues: allIssues, totalFromApi } = await this.fetchAllIssues(lastSyncedAt ?? undefined, projectId);
|
|
19
50
|
let indexed = 0;
|
|
@@ -28,6 +59,9 @@ export class SearchSyncService {
|
|
|
28
59
|
skipped++;
|
|
29
60
|
continue;
|
|
30
61
|
}
|
|
62
|
+
if (issue.updated_at && (await this.store.getItem(issue.id))?.metadata.updated_at === issue.updated_at) {
|
|
63
|
+
continue; // already indexed in this exact version
|
|
64
|
+
}
|
|
31
65
|
const text = `${issue.summary}\n${issue.description ?? ''}`.trim();
|
|
32
66
|
toEmbed.push({ issue, text });
|
|
33
67
|
}
|
|
@@ -57,19 +91,28 @@ export class SearchSyncService {
|
|
|
57
91
|
if (indexedSinceCheckpoint > 0) {
|
|
58
92
|
await this.store.flush();
|
|
59
93
|
}
|
|
60
|
-
|
|
94
|
+
// The watermark is the newest updated_at the server reported, not the local
|
|
95
|
+
// clock: a client clock running ahead of the server would otherwise skip
|
|
96
|
+
// changes for good. A project-scoped sync must not move the shared watermark,
|
|
97
|
+
// since changes in the other projects were not looked at.
|
|
98
|
+
const newestUpdatedAt = allIssues.find(issue => issue.updated_at)?.updated_at;
|
|
99
|
+
if (projectId === undefined && newestUpdatedAt && newestUpdatedAt !== lastSyncedAt) {
|
|
100
|
+
await this.store.setLastSyncedAt(newestUpdatedAt);
|
|
101
|
+
}
|
|
61
102
|
// Persist the best known total for get_search_index_status.
|
|
62
103
|
// Priority: API total_count > full-rebuild count > current store size.
|
|
63
104
|
// The store size fallback handles MantisBT installations that never return
|
|
64
105
|
// total_count and ensures the status tool never shows "total unknown" after
|
|
65
106
|
// any sync has completed.
|
|
66
107
|
const storeCount = await this.store.count();
|
|
67
|
-
const total = totalFromApi ?? (lastSyncedAt === null ?
|
|
108
|
+
const total = totalFromApi ?? (lastSyncedAt === null ? allIssues.length : storeCount);
|
|
68
109
|
await this.store.setLastKnownTotal(total);
|
|
69
110
|
return { indexed, skipped, total };
|
|
70
111
|
}
|
|
71
112
|
async fetchAllIssues(updatedAfter, projectId) {
|
|
72
|
-
const
|
|
113
|
+
const changedIssues = [];
|
|
114
|
+
const updatedAfterMs = updatedAfter ? new Date(updatedAfter).getTime() : undefined;
|
|
115
|
+
let fetchedCount = 0;
|
|
73
116
|
let totalFromApi = null;
|
|
74
117
|
let page = 1;
|
|
75
118
|
while (true) {
|
|
@@ -88,16 +131,31 @@ export class SearchSyncService {
|
|
|
88
131
|
}
|
|
89
132
|
const response = await this.client.get('issues', params);
|
|
90
133
|
const pageIssues = response.issues ?? [];
|
|
91
|
-
|
|
134
|
+
fetchedCount += pageIssues.length;
|
|
135
|
+
// The MantisBT REST API ignores updated_after, sort and direction (they are
|
|
136
|
+
// still sent for API versions that honour them). It always returns issues
|
|
137
|
+
// ordered by last_updated DESC without pulling sticky issues to the top
|
|
138
|
+
// (see filter_get_default() and mc_filter_get_issues()), so the first issue
|
|
139
|
+
// older than the watermark marks the end of the changes. Ties are kept:
|
|
140
|
+
// timestamps have one-second resolution, and sync() skips issues whose
|
|
141
|
+
// indexed version is already current.
|
|
142
|
+
const cut = updatedAfterMs === undefined
|
|
143
|
+
? -1
|
|
144
|
+
: pageIssues.findIndex(issue => issue.updated_at && new Date(issue.updated_at).getTime() < updatedAfterMs);
|
|
145
|
+
const reachedWatermark = cut !== -1;
|
|
146
|
+
changedIssues.push(...(reachedWatermark ? pageIssues.slice(0, cut) : pageIssues));
|
|
92
147
|
// Capture total_count from the first page that provides it
|
|
93
148
|
if (totalFromApi === null && response.total_count != null) {
|
|
94
149
|
totalFromApi = response.total_count;
|
|
95
150
|
}
|
|
96
|
-
// Stop when we have fetched all issues:
|
|
151
|
+
// Stop when we have fetched all changed issues:
|
|
152
|
+
// - the watermark was reached on this page, or
|
|
97
153
|
// - total_count is provided and reached, or
|
|
98
154
|
// - page returned fewer items than requested (last page)
|
|
155
|
+
if (reachedWatermark)
|
|
156
|
+
break;
|
|
99
157
|
if (totalFromApi !== null) {
|
|
100
|
-
if (
|
|
158
|
+
if (fetchedCount >= totalFromApi)
|
|
101
159
|
break;
|
|
102
160
|
}
|
|
103
161
|
else if (pageIssues.length < PAGE_SIZE) {
|
|
@@ -105,6 +163,6 @@ export class SearchSyncService {
|
|
|
105
163
|
}
|
|
106
164
|
page++;
|
|
107
165
|
}
|
|
108
|
-
return { issues:
|
|
166
|
+
return { issues: changedIssues, totalFromApi };
|
|
109
167
|
}
|
|
110
168
|
}
|
package/dist/search/tools.js
CHANGED
|
@@ -26,7 +26,7 @@ function errorText(msg) {
|
|
|
26
26
|
// registerSearchTools
|
|
27
27
|
// ---------------------------------------------------------------------------
|
|
28
28
|
const coerceBool = (val) => val === 'true' ? true : val === 'false' ? false : val;
|
|
29
|
-
export function registerSearchTools(server, client, store, embedder) {
|
|
29
|
+
export function registerSearchTools(server, client, store, embedder, lockDir) {
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
31
31
|
// search_issues
|
|
32
32
|
// ---------------------------------------------------------------------------
|
|
@@ -214,13 +214,9 @@ export function registerSearchTools(server, client, store, embedder) {
|
|
|
214
214
|
},
|
|
215
215
|
}, async ({ project_id, full }) => {
|
|
216
216
|
try {
|
|
217
|
-
if (full) {
|
|
218
|
-
await store.clear();
|
|
219
|
-
await store.resetLastSyncedAt();
|
|
220
|
-
}
|
|
221
217
|
const startMs = Date.now();
|
|
222
|
-
const syncService = new SearchSyncService(client, store, embedder);
|
|
223
|
-
const { indexed, skipped, total } = await syncService.sync(project_id);
|
|
218
|
+
const syncService = new SearchSyncService(client, store, embedder, lockDir);
|
|
219
|
+
const { indexed, skipped, total } = await syncService.sync(project_id, { full });
|
|
224
220
|
const duration_ms = Date.now() - startMs;
|
|
225
221
|
return {
|
|
226
222
|
content: [
|
package/docs/cookbook.de.md
CHANGED
|
@@ -1351,7 +1351,9 @@ Baut den vollständigen Vektor-Suchindex von Grund auf neu auf. Einmalig nach de
|
|
|
1351
1351
|
|
|
1352
1352
|
### Inkrementelles Index-Update
|
|
1353
1353
|
|
|
1354
|
-
Aktualisiert den Index mit Issues, die seit
|
|
1354
|
+
Aktualisiert den Index mit Issues, die seit der letzten Synchronisation hinzugekommen oder geändert wurden. Schneller als ein vollständiger Neuaufbau: Nur die seitdem geänderten Issues werden abgerufen und neu eingebettet.
|
|
1355
|
+
|
|
1356
|
+
Es synchronisiert immer nur ein Serverprozess den gemeinsamen Index. Läuft bereits eine Synchronisation in einem anderen Prozess (etwa einer anderen Client-Sitzung), gibt das Tool einen Fehler zurück – nach deren Ende einfach erneut aufrufen. Eine auf `project_id` beschränkte Synchronisation verschiebt `last_synced_at` nicht, damit Änderungen in anderen Projekten beim nächsten Sync trotzdem erfasst werden.
|
|
1355
1357
|
|
|
1356
1358
|
**Tool:** `rebuild_search_index`
|
|
1357
1359
|
|
|
@@ -1372,7 +1374,7 @@ Aktualisiert den Index mit Issues, die seit dem letzten Aufbau hinzugekommen ode
|
|
|
1372
1374
|
```json
|
|
1373
1375
|
{
|
|
1374
1376
|
"indexed": 14,
|
|
1375
|
-
"skipped":
|
|
1377
|
+
"skipped": 0,
|
|
1376
1378
|
"total": 312,
|
|
1377
1379
|
"duration_ms": 2104
|
|
1378
1380
|
}
|
|
@@ -1382,7 +1384,7 @@ Aktualisiert den Index mit Issues, die seit dem letzten Aufbau hinzugekommen ode
|
|
|
1382
1384
|
|
|
1383
1385
|
### Index-Status prüfen
|
|
1384
1386
|
|
|
1385
|
-
Gibt die Anzahl der indizierten Issues, die Gesamtanzahl der Issues und den Zeitstempel der letzten Synchronisation zurück.
|
|
1387
|
+
Gibt die Anzahl der indizierten Issues, die Gesamtanzahl der Issues und den Zeitstempel der letzten Synchronisation zurück. `last_synced_at` ist das jüngste `updated_at`, das der MantisBT-Server bei dieser Synchronisation gemeldet hat (Serverzeit), und kann daher vor dem tatsächlichen Zeitpunkt der Synchronisation liegen.
|
|
1386
1388
|
|
|
1387
1389
|
**Tool:** `get_search_index_status`
|
|
1388
1390
|
|
|
@@ -1402,7 +1404,7 @@ Gibt die Anzahl der indizierten Issues, die Gesamtanzahl der Issues und den Zeit
|
|
|
1402
1404
|
"indexed": 312,
|
|
1403
1405
|
"total": 312,
|
|
1404
1406
|
"percent": 100,
|
|
1405
|
-
"last_synced_at": "2024-11-05T14:00:00
|
|
1407
|
+
"last_synced_at": "2024-11-05T14:00:00+01:00"
|
|
1406
1408
|
}
|
|
1407
1409
|
```
|
|
1408
1410
|
|
package/docs/cookbook.md
CHANGED
|
@@ -1351,7 +1351,9 @@ Builds the full vector search index from scratch. Run this once after enabling s
|
|
|
1351
1351
|
|
|
1352
1352
|
### Incremental index update
|
|
1353
1353
|
|
|
1354
|
-
Updates the index with issues added or changed since the last
|
|
1354
|
+
Updates the index with issues added or changed since the last sync. Faster than a full rebuild: only issues changed since then are fetched and re-embedded.
|
|
1355
|
+
|
|
1356
|
+
Only one server process syncs the shared index at a time. If another process (for example another client session) is already syncing, the tool returns an error ("A search index sync is already running") — try again once that sync has finished. A sync limited to `project_id` does not advance `last_synced_at`, so changes in other projects are still picked up by the next sync.
|
|
1355
1357
|
|
|
1356
1358
|
**Tool:** `rebuild_search_index`
|
|
1357
1359
|
|
|
@@ -1372,7 +1374,7 @@ Updates the index with issues added or changed since the last build. Faster than
|
|
|
1372
1374
|
```json
|
|
1373
1375
|
{
|
|
1374
1376
|
"indexed": 14,
|
|
1375
|
-
"skipped":
|
|
1377
|
+
"skipped": 0,
|
|
1376
1378
|
"total": 312,
|
|
1377
1379
|
"duration_ms": 2104
|
|
1378
1380
|
}
|
|
@@ -1382,7 +1384,7 @@ Updates the index with issues added or changed since the last build. Faster than
|
|
|
1382
1384
|
|
|
1383
1385
|
### Check index status
|
|
1384
1386
|
|
|
1385
|
-
Returns the number of indexed issues, total issue count, and the timestamp of the last sync.
|
|
1387
|
+
Returns the number of indexed issues, total issue count, and the timestamp of the last sync. `last_synced_at` is the newest `updated_at` the MantisBT server reported at that sync (server time), so it can lie before the actual time of the sync.
|
|
1386
1388
|
|
|
1387
1389
|
**Tool:** `get_search_index_status`
|
|
1388
1390
|
|
|
@@ -1402,7 +1404,7 @@ Returns the number of indexed issues, total issue count, and the timestamp of th
|
|
|
1402
1404
|
"indexed": 312,
|
|
1403
1405
|
"total": 312,
|
|
1404
1406
|
"percent": 100,
|
|
1405
|
-
"last_synced_at": "2024-11-05T14:00:00
|
|
1407
|
+
"last_synced_at": "2024-11-05T14:00:00+01:00"
|
|
1406
1408
|
}
|
|
1407
1409
|
```
|
|
1408
1410
|
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
"name": "io.github.dpesch/mantisbt-mcp-server",
|
|
4
4
|
"title": "MantisBT MCP Server",
|
|
5
5
|
"description": "MantisBT MCP server – manage issues, notes, files, tags, and relationships. With semantic search.",
|
|
6
|
-
"version": "1.14.
|
|
6
|
+
"version": "1.14.1",
|
|
7
7
|
"packages": [
|
|
8
8
|
{
|
|
9
9
|
"registryType": "npm",
|
|
10
10
|
"identifier": "@dpesch/mantisbt-mcp-server",
|
|
11
|
-
"version": "1.14.
|
|
11
|
+
"version": "1.14.1",
|
|
12
12
|
"runtimeHint": "npx",
|
|
13
13
|
"transport": {
|
|
14
14
|
"type": "stdio"
|