@dpesch/mantisbt-mcp-server 1.14.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/README.de.md +118 -29
- package/README.md +118 -29
- package/dist/client.js +4 -0
- package/dist/coerce.js +8 -0
- package/dist/config.js +3 -0
- package/dist/deprecation.js +9 -0
- package/dist/index.js +20 -31
- package/dist/issue-enrich.js +23 -0
- package/dist/issue-envelope.js +4 -0
- package/dist/prompts/index.js +4 -4
- package/dist/resources/index.js +4 -8
- package/dist/search/embedder.js +49 -14
- package/dist/search/index.js +18 -11
- 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 +21 -27
- package/dist/tools/config.js +9 -4
- package/dist/tools/files.js +12 -5
- package/dist/tools/index.js +34 -0
- package/dist/tools/issues.js +285 -147
- package/dist/tools/metadata.js +99 -40
- package/dist/tools/monitors.js +145 -20
- package/dist/tools/notes.js +67 -9
- package/dist/tools/project-versions.js +40 -18
- package/dist/tools/projects.js +109 -37
- package/dist/tools/users.js +3 -1
- package/dist/tools/version.js +61 -35
- package/dist/toolsets.js +104 -0
- package/docs/cookbook.de.md +605 -184
- package/docs/cookbook.md +605 -184
- package/docs/examples.de.md +27 -3
- package/docs/examples.md +27 -3
- package/package.json +2 -1
- package/server.json +2 -2
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Major version in which deprecated tools are removed (see versioning rules in CLAUDE.md). */
|
|
2
|
+
export const REMOVAL_VERSION = '2.0.0';
|
|
3
|
+
/**
|
|
4
|
+
* First line of the description of a deprecated tool. Keeping the wording in
|
|
5
|
+
* one place keeps README, CHANGELOG and tests consistent.
|
|
6
|
+
*/
|
|
7
|
+
export function deprecationNote(tool, replacement) {
|
|
8
|
+
return `Deprecated: use ${replacement} instead — ${tool} will be removed in ${REMOVAL_VERSION}.`;
|
|
9
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -10,19 +10,8 @@ import { getConfig, getStartupConfig, assertHttpAuthConfigured } from './config.
|
|
|
10
10
|
import { MantisClient } from './client.js';
|
|
11
11
|
import { VersionHintService, setGlobalVersionHint } from './version-hint.js';
|
|
12
12
|
import { MetadataCache } from './cache.js';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import { registerFileTools } from './tools/files.js';
|
|
16
|
-
import { registerRelationshipTools } from './tools/relationships.js';
|
|
17
|
-
import { registerMonitorTools } from './tools/monitors.js';
|
|
18
|
-
import { registerProjectTools } from './tools/projects.js';
|
|
19
|
-
import { registerProjectVersionTools } from './tools/project-versions.js';
|
|
20
|
-
import { registerUserTools } from './tools/users.js';
|
|
21
|
-
import { registerFilterTools } from './tools/filters.js';
|
|
22
|
-
import { registerConfigTools } from './tools/config.js';
|
|
23
|
-
import { registerMetadataTools } from './tools/metadata.js';
|
|
24
|
-
import { registerTagTools } from './tools/tags.js';
|
|
25
|
-
import { registerVersionTools } from './tools/version.js';
|
|
13
|
+
import { registerAllTools } from './tools/index.js';
|
|
14
|
+
import { filterToolRegistration } from './toolsets.js';
|
|
26
15
|
import { registerPrompts } from './prompts/index.js';
|
|
27
16
|
import { registerResources } from './resources/index.js';
|
|
28
17
|
// ---------------------------------------------------------------------------
|
|
@@ -55,26 +44,26 @@ async function createMcpServer(transport) {
|
|
|
55
44
|
name: 'mantisbt-mcp-server',
|
|
56
45
|
version,
|
|
57
46
|
});
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
47
|
+
// Tools register through a filter for MANTIS_TOOLSETS / MANTIS_DISABLE_DESTRUCTIVE;
|
|
48
|
+
// prompts and resources use the server directly.
|
|
49
|
+
const tools = filterToolRegistration(server, startupConfig);
|
|
50
|
+
// Optional: Semantic search module — needs MANTIS_SEARCH_ENABLED=true and the
|
|
51
|
+
// "search" tool group (skipping it avoids loading the model and the index).
|
|
52
|
+
// Initialized first so get_metadata can report the index status.
|
|
53
|
+
const searchIndexStatus = startupConfig.search.enabled && startupConfig.toolsets.has('search')
|
|
54
|
+
? await (await import('./search/index.js')).initializeSearchModule(tools, client, startupConfig.search)
|
|
55
|
+
: undefined;
|
|
56
|
+
registerAllTools(tools, {
|
|
57
|
+
client,
|
|
58
|
+
cache,
|
|
59
|
+
versionHint,
|
|
60
|
+
version,
|
|
61
|
+
uploadDir: startupConfig.uploadDir,
|
|
62
|
+
transport,
|
|
63
|
+
searchIndexStatus,
|
|
64
|
+
});
|
|
71
65
|
registerPrompts(server);
|
|
72
66
|
registerResources(server, client, cache, startupConfig.testEnvironment);
|
|
73
|
-
// Optional: Semantic search module
|
|
74
|
-
if (startupConfig.search.enabled) {
|
|
75
|
-
const { initializeSearchModule } = await import('./search/index.js');
|
|
76
|
-
await initializeSearchModule(server, client, startupConfig.search);
|
|
77
|
-
}
|
|
78
67
|
return server;
|
|
79
68
|
}
|
|
80
69
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { buildAttachmentDownloadUrl, buildIssueViewUrl, buildNoteViewUrl } from './client.js';
|
|
2
|
+
/**
|
|
3
|
+
* Adds the links MantisBT's REST API leaves out: view_url for the issue and
|
|
4
|
+
* each note, download_url for each attachment (REST sends it only via SOAP).
|
|
5
|
+
* issueId is the fallback when a select projection excluded the id field.
|
|
6
|
+
*/
|
|
7
|
+
export function enrichIssue(issue, baseUrl, issueId) {
|
|
8
|
+
const id = issue.id ?? issueId;
|
|
9
|
+
if (id === undefined)
|
|
10
|
+
return issue;
|
|
11
|
+
return {
|
|
12
|
+
...issue,
|
|
13
|
+
view_url: buildIssueViewUrl(baseUrl, id),
|
|
14
|
+
notes: issue.notes?.map(note => ({
|
|
15
|
+
...note,
|
|
16
|
+
view_url: buildNoteViewUrl(baseUrl, id, note.id),
|
|
17
|
+
})),
|
|
18
|
+
attachments: issue.attachments?.map(file => ({
|
|
19
|
+
...file,
|
|
20
|
+
download_url: buildAttachmentDownloadUrl(baseUrl, file.id),
|
|
21
|
+
})),
|
|
22
|
+
};
|
|
23
|
+
}
|
package/dist/prompts/index.js
CHANGED
|
@@ -16,7 +16,7 @@ export function registerPrompts(server) {
|
|
|
16
16
|
description: 'Guide the creation of a complete bug report in MantisBT. Project, category, summary, and description are required.',
|
|
17
17
|
argsSchema: {
|
|
18
18
|
project_id: z.coerce.number().int().positive().describe('Numeric project ID'),
|
|
19
|
-
category: z.string().describe('Issue category (call
|
|
19
|
+
category: z.string().describe('Issue category (call get_project with include: ["categories"] to list valid values)'),
|
|
20
20
|
summary: z.string().describe('Short, descriptive summary of the bug'),
|
|
21
21
|
description: z.string().describe('Detailed description of the bug'),
|
|
22
22
|
steps_to_reproduce: z.string().optional().describe('Step-by-step reproduction instructions'),
|
|
@@ -46,7 +46,7 @@ export function registerPrompts(server) {
|
|
|
46
46
|
if (environment) {
|
|
47
47
|
lines.push(``, `Environment:`, environment);
|
|
48
48
|
}
|
|
49
|
-
lines.push(``, `Use the create_issue tool to submit this bug report.`, `Call
|
|
49
|
+
lines.push(``, `Use the create_issue tool to submit this bug report.`, `Call get_issue_schema first if you need to look up valid severity and priority values.`, `Default to severity "minor" and priority "normal" unless the user specifies otherwise.`);
|
|
50
50
|
return userMessage(lines.join('\n'));
|
|
51
51
|
});
|
|
52
52
|
// ---------------------------------------------------------------------------
|
|
@@ -57,7 +57,7 @@ export function registerPrompts(server) {
|
|
|
57
57
|
description: 'Guide the creation of a feature request in MantisBT. Project, category, summary, and description are required.',
|
|
58
58
|
argsSchema: {
|
|
59
59
|
project_id: z.coerce.number().int().positive().describe('Numeric project ID'),
|
|
60
|
-
category: z.string().describe('Issue category (call
|
|
60
|
+
category: z.string().describe('Issue category (call get_project with include: ["categories"] to list valid values)'),
|
|
61
61
|
summary: z.string().describe('Short description of the requested feature'),
|
|
62
62
|
description: z.string().describe('Detailed description of the feature'),
|
|
63
63
|
use_case: z.string().optional().describe('Business use case or motivation for the feature'),
|
|
@@ -75,7 +75,7 @@ export function registerPrompts(server) {
|
|
|
75
75
|
if (use_case) {
|
|
76
76
|
lines.push(``, `Use case / motivation:`, use_case);
|
|
77
77
|
}
|
|
78
|
-
lines.push(``, `Use the create_issue tool to submit this feature request.`, `Call
|
|
78
|
+
lines.push(``, `Use the create_issue tool to submit this feature request.`, `Call get_issue_schema first to look up valid severity values — use severity "feature" and priority "normal" unless the user specifies otherwise.`);
|
|
79
79
|
return userMessage(lines.join('\n'));
|
|
80
80
|
});
|
|
81
81
|
// ---------------------------------------------------------------------------
|
package/dist/resources/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { fetchIssueEnums } from '../tools/config.js';
|
|
3
|
-
import { normalizeProject } from '../tools/metadata.js';
|
|
4
|
-
import { ALL_PROJECTS_PREFIX } from '../constants.js';
|
|
3
|
+
import { normalizeProject, stripAllProjectsPrefix } from '../tools/metadata.js';
|
|
5
4
|
function jsonResource(uri, data) {
|
|
6
5
|
return {
|
|
7
6
|
contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(data) }],
|
|
@@ -21,7 +20,7 @@ export function registerResources(server, client, cache, testEnvironment = false
|
|
|
21
20
|
}, async (uri) => jsonResource(uri, await client.get('users/me')));
|
|
22
21
|
server.registerResource('projects', 'mantis://projects', {
|
|
23
22
|
title: 'Projects',
|
|
24
|
-
description: 'All MantisBT projects accessible to the current API user. Served from local cache when fresh; falls back to live fetch. Refresh via
|
|
23
|
+
description: 'All MantisBT projects accessible to the current API user. Served from local cache when fresh; falls back to live fetch. Refresh via get_metadata with refresh: true.',
|
|
25
24
|
mimeType: 'application/json',
|
|
26
25
|
}, async (uri) => jsonResource(uri, await loadProjects()));
|
|
27
26
|
server.registerResource('project-detail', new ResourceTemplate('mantis://projects/{id}', {
|
|
@@ -40,7 +39,7 @@ export function registerResources(server, client, cache, testEnvironment = false
|
|
|
40
39
|
},
|
|
41
40
|
}), {
|
|
42
41
|
title: 'Project Detail',
|
|
43
|
-
description: 'Combined project view: fields (status, view_state, access_level, description) plus all associated users, versions, and categories. Served from local cache when fresh; falls back to live API fetch. Refresh via
|
|
42
|
+
description: 'Combined project view: fields (status, view_state, access_level, description) plus all associated users, versions, and categories. Served from local cache when fresh; falls back to live API fetch. Refresh via get_metadata with refresh: true.',
|
|
44
43
|
mimeType: 'application/json',
|
|
45
44
|
}, async (uri, { id }) => {
|
|
46
45
|
const numId = Number(id);
|
|
@@ -66,10 +65,7 @@ export function registerResources(server, client, cache, testEnvironment = false
|
|
|
66
65
|
project = raw ? normalizeProject(raw) : undefined;
|
|
67
66
|
users = usersResult.users ?? [];
|
|
68
67
|
versions = versionsResult.versions ?? [];
|
|
69
|
-
categories = (raw?.categories ?? [])
|
|
70
|
-
...c,
|
|
71
|
-
name: c.name.startsWith(ALL_PROJECTS_PREFIX) ? c.name.slice(ALL_PROJECTS_PREFIX.length) : c.name,
|
|
72
|
-
}));
|
|
68
|
+
categories = stripAllProjectsPrefix(raw?.categories ?? []);
|
|
73
69
|
}
|
|
74
70
|
if (!project) {
|
|
75
71
|
throw new Error(`Project ${numId} not found`);
|
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,25 +1,32 @@
|
|
|
1
1
|
import { createVectorStore } from './store.js';
|
|
2
2
|
import { Embedder } from './embedder.js';
|
|
3
|
-
import { registerSearchTools } from './tools.js';
|
|
4
|
-
import { SearchSyncService } from './sync.js';
|
|
3
|
+
import { registerSearchTools, getSearchIndexStatus } from './tools.js';
|
|
4
|
+
import { SearchSyncService, SyncLockedError } from './sync.js';
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
// initializeSearchModule
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
export async function initializeSearchModule(server, client, config) {
|
|
9
|
-
if (!config.enabled)
|
|
10
|
-
return;
|
|
11
9
|
const store = createVectorStore(config.backend, config.dir);
|
|
12
10
|
const embedder = new Embedder(config.modelName, config.numThreads);
|
|
13
|
-
registerSearchTools(server, client, store, embedder);
|
|
14
|
-
// Pre-initialize lastKnownTotal so
|
|
11
|
+
registerSearchTools(server, client, store, embedder, config.dir);
|
|
12
|
+
// Pre-initialize lastKnownTotal so the index status (get_metadata) shows a value
|
|
15
13
|
// immediately on startup, even while the background sync is still running.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
// count() loads the whole index into memory, and this runs before the server
|
|
15
|
+
// connects — so only do it when there is no stored total yet.
|
|
16
|
+
if (await store.getLastKnownTotal() === null) {
|
|
17
|
+
const startupCount = await store.count();
|
|
18
|
+
if (startupCount > 0)
|
|
19
|
+
await store.setLastKnownTotal(startupCount);
|
|
19
20
|
}
|
|
20
|
-
// Non-blocking background sync on startup
|
|
21
|
-
|
|
21
|
+
// Non-blocking background sync on startup. If another server process (another
|
|
22
|
+
// client session) is already syncing the shared index, this one skips it.
|
|
23
|
+
const syncService = new SearchSyncService(client, store, embedder, config.dir);
|
|
22
24
|
syncService.sync().catch((err) => {
|
|
25
|
+
if (err instanceof SyncLockedError) {
|
|
26
|
+
console.error('[mantisbt-search] another process is syncing the index, skipping startup sync');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
23
29
|
console.error('[mantisbt-search] sync error:', err instanceof Error ? err.message : String(err));
|
|
24
30
|
});
|
|
31
|
+
return () => getSearchIndexStatus(store);
|
|
25
32
|
}
|
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
|
}
|