@dpesch/mantisbt-mcp-server 1.13.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 CHANGED
@@ -11,6 +11,35 @@ 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
+
26
+ ## [1.14.0] – 2026-09-27
27
+
28
+ ### Fixed
29
+
30
+ - `create_issue` / `update_issue`: a `custom_fields` entry without `value` is now rejected with a validation error. Previously it was accepted and the literal string `"undefined"` was written to the custom field.
31
+
32
+ ### Changed
33
+
34
+ - **Minimum Node.js version is now 22** (`engines: >=22`). Node.js 18 and 20 are end-of-life and no longer receive security updates; the test toolchain (vitest 5) no longer runs on them, so they could not be tested anymore. CI now runs on Node.js 22. Users on Node.js 20 or older should upgrade — npm only warns about the `engines` mismatch, but these versions are no longer tested.
35
+ - Dependencies: `zod` 4 (the MCP SDK supports `^3.25 || ^4`). Tool parameters, types and defaults are unchanged. The published JSON schemas differ slightly: nested `$ref`s are inlined, integer IDs carry an explicit `maximum`, and objects no longer declare `additionalProperties: false` (unknown keys were and still are ignored).
36
+ - Dependencies: `@huggingface/transformers` 4 for the optional semantic search. Embeddings are numerically identical to version 3 (verified with the default model), so existing search indexes stay valid — no rebuild needed. This also resolves the `sharp` security advisories reported by `npm audit`.
37
+ - Dependencies: `@modelcontextprotocol/sdk` minimum raised to ^1.30.1; transitive dependencies updated to resolve all `npm audit` advisories (including a critical one in `tar`).
38
+ - Dev dependencies: vitest and `@vitest/coverage-v8` 5, `@types/node` 22 (matching the minimum Node.js version).
39
+ - CI: the publish job now checks `NPM_TOKEN` and `GH_RELEASE_TOKEN` before `npm publish`, so an empty or invalid token stops the release before anything is published. Each publish step (npm, Codeberg release, GitHub release) is skipped when its target already exists, so re-running a failed job only catches up on what is missing.
40
+
41
+ ---
42
+
14
43
  ## [1.13.0] – 2026-09-27
15
44
 
16
45
  ### Added
package/README.de.md CHANGED
@@ -12,7 +12,7 @@ Ein [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) Server, der
12
12
 
13
13
  ## Voraussetzungen
14
14
 
15
- - Node.js ≥ 18
15
+ - Node.js ≥ 22
16
16
  - MantisBT-Installation mit aktivierter REST-API (ab Version 2.23)
17
17
  - MantisBT API-Token (unter *Mein Konto → API-Token* erstellen)
18
18
 
@@ -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
@@ -12,7 +12,7 @@ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that i
12
12
 
13
13
  ## Requirements
14
14
 
15
- - Node.js ≥ 18
15
+ - Node.js ≥ 22
16
16
  - MantisBT installation with REST API enabled (version 2.23+)
17
17
  - MantisBT API token (create under *My Account → API Tokens*)
18
18
 
@@ -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
 
@@ -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
- this.pipe = await transformers.pipeline('feature-extraction', this.modelName, {
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
- const extractor = await this.load();
40
- const output = await extractor(text, { pooling: 'mean', normalize: true });
41
- return extractVector(output);
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
- const extractor = await this.load();
47
- const output = await extractor(texts, { pooling: 'mean', normalize: true });
48
- if (Array.isArray(output)) {
49
- return output.map(extractVector);
50
- }
51
- // Some versions return a single tensor for batch input
52
- return extractBatchVectors(output, texts.length);
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
  // ---------------------------------------------------------------------------
@@ -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
- const syncService = new SearchSyncService(client, store, embedder);
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
  }
@@ -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 indexFile = join(this.vectraDir, 'index.json');
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
+ }
@@ -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
- constructor(client, store, embedder) {
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 sync(projectId) {
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
- await this.store.setLastSyncedAt(new Date().toISOString());
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 ? indexed + skipped : storeCount);
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 allIssues = [];
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
- allIssues.push(...pageIssues);
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 (allIssues.length >= totalFromApi)
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: allIssues, totalFromApi };
166
+ return { issues: changedIssues, totalFromApi };
109
167
  }
110
168
  }
@@ -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
  // ---------------------------------------------------------------------------
@@ -47,8 +47,7 @@ export function registerSearchTools(server, client, store, embedder) {
47
47
  'When provided, each matching issue is fetched from MantisBT and enriched with the requested fields. ' +
48
48
  'The relevance score is always included. Without this parameter only id and score are returned.'),
49
49
  highlight: z
50
- .preprocess(coerceBool, z.boolean())
51
- .default(false)
50
+ .preprocess(coerceBool, z.boolean().default(false))
52
51
  .describe('If true, adds a "highlights" field per result with query terms bolded (**term**) ' +
53
52
  'in the issue summary and a short description snippet. ' +
54
53
  'Note: highlights are keyword-based, not semantic — some results may have no highlighted terms.'),
@@ -215,13 +214,9 @@ export function registerSearchTools(server, client, store, embedder) {
215
214
  },
216
215
  }, async ({ project_id, full }) => {
217
216
  try {
218
- if (full) {
219
- await store.clear();
220
- await store.resetLastSyncedAt();
221
- }
222
217
  const startMs = Date.now();
223
- const syncService = new SearchSyncService(client, store, embedder);
224
- 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 });
225
220
  const duration_ms = Date.now() - startMs;
226
221
  return {
227
222
  content: [
@@ -85,8 +85,8 @@ By default, obsolete and inherited parent-project versions are excluded. Set obs
85
85
  Prerequisites: obtain project_id from list_projects.`,
86
86
  inputSchema: z.object({
87
87
  project_id: z.coerce.number().int().positive().describe('Numeric project ID — use list_projects to discover project IDs'),
88
- obsolete: z.preprocess(coerceBool, z.boolean()).default(false).describe('Include obsolete (deprecated) versions in the response. Default: false. Set to true to see all versions including those no longer actively used.'),
89
- inherit: z.preprocess(coerceBool, z.boolean()).default(false).describe('Include versions inherited from parent projects. Default: false. Set to true for sub-projects that share versions with a parent project.'),
88
+ obsolete: z.preprocess(coerceBool, z.boolean().default(false)).describe('Include obsolete (deprecated) versions in the response. Default: false. Set to true to see all versions including those no longer actively used.'),
89
+ inherit: z.preprocess(coerceBool, z.boolean().default(false)).describe('Include versions inherited from parent projects. Default: false. Set to true for sub-projects that share versions with a parent project.'),
90
90
  }),
91
91
  annotations: {
92
92
  readOnlyHint: true,
@@ -27,7 +27,7 @@ export function registerVersionTools(server, client, versionHint, mcpVersion) {
27
27
  The version is read from the X-Mantis-Version response header sent by every API call.
28
28
  The GitHub comparison requires an outbound HTTPS request to the GitHub API.`,
29
29
  inputSchema: z.object({
30
- check_latest: z.preprocess(coerceBool, z.boolean()).default(true).describe('Whether to fetch the latest release from GitHub and compare (default: true)'),
30
+ check_latest: z.preprocess(coerceBool, z.boolean().default(true)).describe('Whether to fetch the latest release from GitHub and compare (default: true)'),
31
31
  }),
32
32
  annotations: {
33
33
  readOnlyHint: true,
@@ -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 dem letzten Aufbau hinzugekommen oder geändert wurden. Schneller als ein vollständiger Neuaufbau.
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": 298,
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.000Z"
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 build. Faster than a full rebuild.
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": 298,
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.000Z"
1407
+ "last_synced_at": "2024-11-05T14:00:00+01:00"
1406
1408
  }
1407
1409
  ```
1408
1410
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dpesch/mantisbt-mcp-server",
3
- "version": "1.13.0",
3
+ "version": "1.14.1",
4
4
  "mcpName": "io.github.dpesch/mantisbt-mcp-server",
5
5
  "description": "MCP server for MantisBT REST API – read and manage bug tracker issues",
6
6
  "author": "Dominik Pesch",
@@ -44,18 +44,18 @@
44
44
  "init": "node scripts/init.mjs"
45
45
  },
46
46
  "dependencies": {
47
- "@huggingface/transformers": "^3.0.0",
48
- "@modelcontextprotocol/sdk": "^1.0.0",
49
- "zod": "^3.22.4"
47
+ "@huggingface/transformers": "^4.3.0",
48
+ "@modelcontextprotocol/sdk": "^1.30.1",
49
+ "zod": "^4.6.5"
50
50
  },
51
51
  "devDependencies": {
52
- "@types/node": "^20.0.0",
53
- "@vitest/coverage-v8": "^4.1.5",
54
- "tsx": "^4.0.0",
55
- "typescript": "^5.3.0",
56
- "vitest": "^4.1.5"
52
+ "@types/node": "^22.20.4",
53
+ "@vitest/coverage-v8": "^5.0.2",
54
+ "tsx": "^4.23.15",
55
+ "typescript": "^5.9.3",
56
+ "vitest": "^5.0.2"
57
57
  },
58
58
  "engines": {
59
- "node": ">=18"
59
+ "node": ">=22"
60
60
  }
61
61
  }
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.13.0",
6
+ "version": "1.14.1",
7
7
  "packages": [
8
8
  {
9
9
  "registryType": "npm",
10
10
  "identifier": "@dpesch/mantisbt-mcp-server",
11
- "version": "1.13.0",
11
+ "version": "1.14.1",
12
12
  "runtimeHint": "npx",
13
13
  "transport": {
14
14
  "type": "stdio"