@dpesch/mantisbt-mcp-server 1.1.0 → 1.3.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 +38 -0
- package/README.de.md +45 -2
- package/README.md +45 -2
- package/dist/client.js +13 -0
- package/dist/config.js +39 -1
- package/dist/index.js +7 -2
- package/dist/search/embedder.js +67 -0
- package/dist/search/index.js +25 -0
- package/dist/search/store.js +138 -0
- package/dist/search/sync.js +97 -0
- package/dist/search/tools.js +149 -0
- package/dist/tools/config.js +106 -10
- package/dist/tools/files.js +71 -1
- package/dist/tools/issues.js +13 -14
- package/dist/tools/metadata.js +56 -17
- package/dist/tools/monitors.js +28 -1
- package/dist/tools/notes.js +4 -4
- package/dist/tools/projects.js +13 -6
- package/dist/tools/relationships.js +32 -3
- package/dist/tools/tags.js +4 -4
- package/dist/tools/version.js +16 -1
- package/package.json +4 -1
- package/scripts/record-fixtures.ts +1 -1
- package/tests/cache.test.ts +1 -0
- package/tests/fixtures/get_current_user.json +6 -5
- package/tests/fixtures/get_issue.json +43 -83
- package/tests/fixtures/get_issue_fields_sample.json +17 -51
- package/tests/fixtures/get_project_categories.json +99 -2
- package/tests/fixtures/get_project_versions_with_data.json +35 -11
- package/tests/fixtures/list_issues.json +51 -57
- package/tests/fixtures/list_projects.json +45 -45
- package/tests/helpers/mock-server.ts +38 -4
- package/tests/helpers/search-mocks.ts +60 -0
- package/tests/search/store.test.ts +148 -0
- package/tests/search/sync.test.ts +195 -0
- package/tests/search/tools.test.ts +257 -0
- package/tests/tools/config.test.ts +97 -0
- package/tests/tools/files.test.ts +274 -0
- package/tests/tools/issues.test.ts +57 -9
- package/tests/tools/metadata.test.ts +122 -0
- package/tests/tools/monitors.test.ts +101 -0
- package/tests/tools/projects.test.ts +31 -0
- package/tests/tools/relationships.test.ts +102 -0
- package/tests/tools/string-coercion.test.ts +251 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,44 @@ This project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [1.3.0] – 2026-03-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- New tool `get_search_index_status`: returns the current fill level of the semantic search index — how many issues are indexed vs. total, plus the timestamp of the last sync. Only active when `MANTIS_SEARCH_ENABLED=true`.
|
|
14
|
+
- New tool `get_issue_enums`: returns structured ID/name pairs for all issue enum fields (severity, status, priority, resolution, reproducibility) — ready for direct use in `create_issue` / `update_issue` without requiring knowledge of MantisBT-internal config option names.
|
|
15
|
+
- `sync_metadata` now fetches and caches all tags globally (`tags` field at root level of the cached metadata). When the dedicated `GET /tags` endpoint is unavailable (MantisBT < 2.26), tags are collected by scanning all issues across all projects (`select=id,tags`).
|
|
16
|
+
- New tool `get_mcp_version`: returns the version of the running mantisbt-mcp-server instance.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- `list_tags` now falls back to the metadata cache when `GET /tags` returns 404 instead of returning an error. Run `sync_metadata` first to populate the cache.
|
|
20
|
+
- Numeric ID parameters now accept string inputs (e.g. `"1940"`) — MCP clients that pass IDs as strings no longer receive error -32602.
|
|
21
|
+
- `create_issue` now always sends a `severity` to MantisBT (default: `"minor"`). Previously omitting severity caused MantisBT to store `0`, which was displayed as `@0@`.
|
|
22
|
+
- `get_search_index_status` now correctly reports the total issue count on MantisBT installations that do not return `total_count` in the issues list API. The total is persisted after every sync: `total_count` from the API takes precedence, otherwise the current store size is used as a best-effort estimate. The status tool will therefore no longer show "total unknown" after any sync has completed.
|
|
23
|
+
- `sync_metadata` tags endpoint failure now degrades gracefully to an empty array instead of propagating an error.
|
|
24
|
+
- `sync_metadata` now correctly populates `byProject[id].categories`.
|
|
25
|
+
- `sync_metadata` now fetches all versions including obsolete and inherited ones (`obsolete=1&inherit=1`). Previously only non-obsolete versions were returned, causing the version count in the cache to be far too low.
|
|
26
|
+
- `get_project_versions` now accepts optional `obsolete` and `inherit` boolean parameters to include obsolete and/or inherited versions (both default to `false`). Previously the wrong endpoint (`projects/{id}/categories`) was called, which returned an empty array on many installations. Categories are now read from the project detail response (`projects/{id}`), identical to the source used by `get_project_categories`.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## [1.2.0] – 2026-03-16
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- New tool `remove_relationship`: removes a relationship from an issue. The `relationship_id` is the numeric `id` field on the relationship object returned by `get_issue` (not the type id).
|
|
34
|
+
- New tool `remove_monitor`: removes a user as a monitor of an issue by username.
|
|
35
|
+
- New tool `upload_file`: uploads a file to an issue via multipart/form-data. Supports two input modes: a local `file_path` (filename derived from path) or Base64-encoded `content` with an explicit `filename`. Optional parameters: `filename` (overrides derived name), `content_type` (default: `application/octet-stream`), `description`.
|
|
36
|
+
- New optional semantic search module (`MANTIS_SEARCH_ENABLED=true`): indexes all MantisBT issues as local vector embeddings using `@huggingface/transformers` (ONNX, no external API required). Two new tools:
|
|
37
|
+
- `search_issues` — natural language search over all indexed issues, returns top-N results by cosine similarity score.
|
|
38
|
+
- `rebuild_search_index` — build or incrementally update the search index; `full: true` clears and rebuilds from scratch.
|
|
39
|
+
- Vector store: `vectra` (pure JS, default) or `sqlite-vec` (optional, requires manual installation).
|
|
40
|
+
- Incremental sync on every server start via `updated_at` timestamp.
|
|
41
|
+
- Configuration: `MANTIS_SEARCH_ENABLED`, `MANTIS_SEARCH_BACKEND`, `MANTIS_SEARCH_DIR`, `MANTIS_SEARCH_MODEL`.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
- `list_issues` recorded-fixture tests were fragile: status filter counts are now derived dynamically from the fixture instead of hardcoded assumptions.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
10
48
|
## [1.1.0] – 2026-03-15
|
|
11
49
|
|
|
12
50
|
### Added
|
package/README.de.md
CHANGED
|
@@ -69,6 +69,10 @@ npm run build
|
|
|
69
69
|
| `MANTIS_CACHE_TTL` | – | `3600` | Cache-Lebensdauer in Sekunden |
|
|
70
70
|
| `TRANSPORT` | – | `stdio` | Transport-Modus: `stdio` oder `http` |
|
|
71
71
|
| `PORT` | – | `3000` | Port für HTTP-Modus |
|
|
72
|
+
| `MANTIS_SEARCH_ENABLED` | – | `false` | Auf `true` setzen, um die semantische Suche zu aktivieren |
|
|
73
|
+
| `MANTIS_SEARCH_BACKEND` | – | `vectra` | Vektorspeicher: `vectra` (reines JS) oder `sqlite-vec` (manuelle Installation erforderlich) |
|
|
74
|
+
| `MANTIS_SEARCH_DIR` | – | `{MANTIS_CACHE_DIR}/search` | Verzeichnis für den Suchindex |
|
|
75
|
+
| `MANTIS_SEARCH_MODEL` | – | `Xenova/paraphrase-multilingual-MiniLM-L12-v2` | Embedding-Modell (wird beim ersten Start einmalig heruntergeladen, ~80 MB) |
|
|
72
76
|
|
|
73
77
|
### Config-Datei (Fallback)
|
|
74
78
|
|
|
@@ -106,24 +110,27 @@ Falls keine Umgebungsvariablen gesetzt sind, wird `~/.claude/mantis.json` ausgel
|
|
|
106
110
|
| Tool | Beschreibung |
|
|
107
111
|
|---|---|
|
|
108
112
|
| `list_issue_files` | Anhänge eines Issues auflisten |
|
|
113
|
+
| `upload_file` | Datei an ein Issue anhängen – entweder per lokalem `file_path` oder Base64-kodiertem `content` + `filename` |
|
|
109
114
|
|
|
110
115
|
### Beziehungen
|
|
111
116
|
|
|
112
117
|
| Tool | Beschreibung |
|
|
113
118
|
|---|---|
|
|
114
119
|
| `add_relationship` | Beziehung zwischen zwei Issues erstellen |
|
|
120
|
+
| `remove_relationship` | Beziehung von einem Issue entfernen (die `id` aus dem Beziehungsobjekt verwenden, nicht die type-ID) |
|
|
115
121
|
|
|
116
122
|
### Beobachter
|
|
117
123
|
|
|
118
124
|
| Tool | Beschreibung |
|
|
119
125
|
|---|---|
|
|
120
126
|
| `add_monitor` | Sich selbst als Beobachter eines Issues eintragen |
|
|
127
|
+
| `remove_monitor` | Benutzer als Beobachter eines Issues austragen |
|
|
121
128
|
|
|
122
129
|
### Tags
|
|
123
130
|
|
|
124
131
|
| Tool | Beschreibung |
|
|
125
132
|
|---|---|
|
|
126
|
-
| `list_tags` | Alle verfügbaren Tags auflisten |
|
|
133
|
+
| `list_tags` | Alle verfügbaren Tags auflisten; greift auf den Metadaten-Cache zurück, wenn `GET /tags` mit 404 antwortet (vorher `sync_metadata` ausführen) |
|
|
127
134
|
| `attach_tags` | Tags an ein Issue hängen |
|
|
128
135
|
| `detach_tag` | Tag von einem Issue entfernen |
|
|
129
136
|
|
|
@@ -132,10 +139,44 @@ Falls keine Umgebungsvariablen gesetzt sind, wird `~/.claude/mantis.json` ausgel
|
|
|
132
139
|
| Tool | Beschreibung |
|
|
133
140
|
|---|---|
|
|
134
141
|
| `list_projects` | Alle zugänglichen Projekte auflisten |
|
|
135
|
-
| `get_project_versions` | Versionen eines Projekts abrufen |
|
|
142
|
+
| `get_project_versions` | Versionen eines Projekts abrufen; optionale Booleans `obsolete` und `inherit` für veraltete bzw. vom Elternprojekt geerbte Versionen |
|
|
136
143
|
| `get_project_categories` | Kategorien eines Projekts abrufen |
|
|
137
144
|
| `get_project_users` | Benutzer eines Projekts abrufen |
|
|
138
145
|
|
|
146
|
+
### Semantische Suche *(optional)*
|
|
147
|
+
|
|
148
|
+
Statt einfachem Keyword-Matching versteht die semantische Suche die *Bedeutung* einer Anfrage. Formuliere in natürlicher Sprache — die Suche findet konzeptuell verwandte Issues, auch wenn die genauen Begriffe nicht übereinstimmen:
|
|
149
|
+
|
|
150
|
+
- *„Login funktioniert nach Passwort-Reset nicht"* — findet Issues rund um Authentifizierungsgrenzfälle
|
|
151
|
+
- *„Performance-Probleme auf der Checkout-Seite"* — liefert verwandte Meldungen unabhängig von der verwendeten Terminologie
|
|
152
|
+
- *„doppelte Einträge in der Rechnungsliste"* — erkennt auch Issues, die als „zweifach angezeigt", „dupliziert" o.ä. beschrieben sind
|
|
153
|
+
|
|
154
|
+
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).
|
|
155
|
+
|
|
156
|
+
Aktivierung mit `MANTIS_SEARCH_ENABLED=true`.
|
|
157
|
+
|
|
158
|
+
| Tool | Beschreibung |
|
|
159
|
+
|---|---|
|
|
160
|
+
| `search_issues` | Natürlichsprachige Suche über alle indizierten Issues – liefert Top-N-Ergebnisse mit Cosine-Similarity-Score |
|
|
161
|
+
| `rebuild_search_index` | Suchindex aufbauen oder aktualisieren; `full: true` löscht und baut ihn vollständig neu |
|
|
162
|
+
| `get_search_index_status` | Aktuellen Füllstand des Suchindex zurückgeben: wie viele Issues bereits indiziert sind im Verhältnis zur Gesamtanzahl, plus Zeitstempel der letzten Synchronisation |
|
|
163
|
+
|
|
164
|
+
#### Welches Backend wählen?
|
|
165
|
+
|
|
166
|
+
| | `vectra` *(Standard)* | `sqlite-vec` |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| Abhängigkeiten | Keine (reines JS) | Benötigt native Build-Tools |
|
|
169
|
+
| Installation | Enthalten | `npm install sqlite-vec better-sqlite3` |
|
|
170
|
+
| Geeignet für | Bis ~10.000 Issues | Ab 10.000 Issues |
|
|
171
|
+
| Performance | Für die meisten Instanzen ausreichend | Schneller bei großen Datenmengen |
|
|
172
|
+
|
|
173
|
+
Mit `vectra` starten. Zu `sqlite-vec` wechseln, wenn Indexierungszeiten oder Abfragen spürbar langsam werden.
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
npm install sqlite-vec better-sqlite3
|
|
177
|
+
# dann MANTIS_SEARCH_BACKEND=sqlite-vec setzen
|
|
178
|
+
```
|
|
179
|
+
|
|
139
180
|
### Metadaten & System
|
|
140
181
|
|
|
141
182
|
| Tool | Beschreibung |
|
|
@@ -147,7 +188,9 @@ Falls keine Umgebungsvariablen gesetzt sind, wird `~/.claude/mantis.json` ausgel
|
|
|
147
188
|
| `get_current_user` | Eigenes Benutzerprofil abrufen |
|
|
148
189
|
| `list_languages` | Verfügbare Sprachen auflisten |
|
|
149
190
|
| `get_config` | Server-Konfiguration (Basis-URL, Cache-TTL) anzeigen |
|
|
191
|
+
| `get_issue_enums` | Gültige ID/Name-Paare für alle Enum-Felder zurückgeben (Severity, Status, Priority, Resolution, Reproducibility) — vor `create_issue` / `update_issue` verwenden, um korrekte Werte nachzuschlagen |
|
|
150
192
|
| `get_mantis_version` | MantisBT-Version abrufen und auf Updates prüfen |
|
|
193
|
+
| `get_mcp_version` | Version dieser mantisbt-mcp-server-Instanz zurückgeben |
|
|
151
194
|
|
|
152
195
|
## HTTP-Modus
|
|
153
196
|
|
package/README.md
CHANGED
|
@@ -69,6 +69,10 @@ npm run build
|
|
|
69
69
|
| `MANTIS_CACHE_TTL` | – | `3600` | Cache lifetime in seconds |
|
|
70
70
|
| `TRANSPORT` | – | `stdio` | Transport mode: `stdio` or `http` |
|
|
71
71
|
| `PORT` | – | `3000` | Port for HTTP mode |
|
|
72
|
+
| `MANTIS_SEARCH_ENABLED` | – | `false` | Set to `true` to enable semantic search |
|
|
73
|
+
| `MANTIS_SEARCH_BACKEND` | – | `vectra` | Vector store backend: `vectra` (pure JS) or `sqlite-vec` (requires manual install) |
|
|
74
|
+
| `MANTIS_SEARCH_DIR` | – | `{MANTIS_CACHE_DIR}/search` | Directory for the search index |
|
|
75
|
+
| `MANTIS_SEARCH_MODEL` | – | `Xenova/paraphrase-multilingual-MiniLM-L12-v2` | Embedding model name (downloaded once on first use, ~80 MB) |
|
|
72
76
|
|
|
73
77
|
### Config file (fallback)
|
|
74
78
|
|
|
@@ -106,24 +110,27 @@ If no environment variables are set, `~/.claude/mantis.json` is read:
|
|
|
106
110
|
| Tool | Description |
|
|
107
111
|
|---|---|
|
|
108
112
|
| `list_issue_files` | List attachments of an issue |
|
|
113
|
+
| `upload_file` | Upload a file to an issue — either by local `file_path` or Base64-encoded `content` + `filename` |
|
|
109
114
|
|
|
110
115
|
### Relationships
|
|
111
116
|
|
|
112
117
|
| Tool | Description |
|
|
113
118
|
|---|---|
|
|
114
119
|
| `add_relationship` | Create a relationship between two issues |
|
|
120
|
+
| `remove_relationship` | Remove a relationship from an issue (use the `id` from the relationship object, not the type) |
|
|
115
121
|
|
|
116
122
|
### Monitors
|
|
117
123
|
|
|
118
124
|
| Tool | Description |
|
|
119
125
|
|---|---|
|
|
120
126
|
| `add_monitor` | Add yourself as a monitor of an issue |
|
|
127
|
+
| `remove_monitor` | Remove a user as a monitor of an issue |
|
|
121
128
|
|
|
122
129
|
### Tags
|
|
123
130
|
|
|
124
131
|
| Tool | Description |
|
|
125
132
|
|---|---|
|
|
126
|
-
| `list_tags` | List all available tags |
|
|
133
|
+
| `list_tags` | List all available tags; falls back to the metadata cache when `GET /tags` returns 404 (run `sync_metadata` first to populate) |
|
|
127
134
|
| `attach_tags` | Attach tags to an issue |
|
|
128
135
|
| `detach_tag` | Remove a tag from an issue |
|
|
129
136
|
|
|
@@ -132,10 +139,44 @@ If no environment variables are set, `~/.claude/mantis.json` is read:
|
|
|
132
139
|
| Tool | Description |
|
|
133
140
|
|---|---|
|
|
134
141
|
| `list_projects` | List all accessible projects |
|
|
135
|
-
| `get_project_versions` | Get versions of a project |
|
|
142
|
+
| `get_project_versions` | Get versions of a project; optional `obsolete` and `inherit` booleans to include obsolete or parent-inherited versions |
|
|
136
143
|
| `get_project_categories` | Get categories of a project |
|
|
137
144
|
| `get_project_users` | Get users of a project |
|
|
138
145
|
|
|
146
|
+
### Semantic search *(optional)*
|
|
147
|
+
|
|
148
|
+
Instead of exact keyword matching, semantic search understands the *meaning* behind a query. Ask in plain language — the search engine finds conceptually related issues even when the wording doesn't match:
|
|
149
|
+
|
|
150
|
+
- *"login fails after password reset"* — finds issues about authentication edge cases
|
|
151
|
+
- *"performance problems on the checkout page"* — surfaces related reports regardless of the exact terminology used
|
|
152
|
+
- *"duplicate entries in the invoice list"* — catches issues described as "shown twice", "double records", etc.
|
|
153
|
+
|
|
154
|
+
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).
|
|
155
|
+
|
|
156
|
+
Activate with `MANTIS_SEARCH_ENABLED=true`.
|
|
157
|
+
|
|
158
|
+
| Tool | Description |
|
|
159
|
+
|---|---|
|
|
160
|
+
| `search_issues` | Natural language search over all indexed issues — returns top-N results with cosine similarity score |
|
|
161
|
+
| `rebuild_search_index` | Build or update the search index; `full: true` clears and rebuilds from scratch |
|
|
162
|
+
| `get_search_index_status` | Return the current fill level of the search index: how many issues are indexed vs. total, and the timestamp of the last sync |
|
|
163
|
+
|
|
164
|
+
#### Which backend to choose?
|
|
165
|
+
|
|
166
|
+
| | `vectra` *(default)* | `sqlite-vec` |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| Dependencies | None (pure JS) | Requires native build tools |
|
|
169
|
+
| Install | Included | `npm install sqlite-vec better-sqlite3` |
|
|
170
|
+
| Best for | Up to ~10,000 issues | 10,000+ issues |
|
|
171
|
+
| Performance | Fast enough for most setups | Faster for large corpora |
|
|
172
|
+
|
|
173
|
+
Start with `vectra`. Switch to `sqlite-vec` if indexing or query times become noticeably slow.
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
npm install sqlite-vec better-sqlite3
|
|
177
|
+
# then set MANTIS_SEARCH_BACKEND=sqlite-vec
|
|
178
|
+
```
|
|
179
|
+
|
|
139
180
|
### Metadata & system
|
|
140
181
|
|
|
141
182
|
| Tool | Description |
|
|
@@ -147,7 +188,9 @@ If no environment variables are set, `~/.claude/mantis.json` is read:
|
|
|
147
188
|
| `get_current_user` | Retrieve your own user profile |
|
|
148
189
|
| `list_languages` | List available languages |
|
|
149
190
|
| `get_config` | Show server configuration (base URL, cache TTL) |
|
|
191
|
+
| `get_issue_enums` | Return valid ID/name pairs for all issue enum fields (severity, status, priority, resolution, reproducibility) — use before `create_issue` / `update_issue` to look up correct values |
|
|
150
192
|
| `get_mantis_version` | Get MantisBT version and check for updates |
|
|
193
|
+
| `get_mcp_version` | Return the version of this mantisbt-mcp-server instance |
|
|
151
194
|
|
|
152
195
|
## HTTP mode
|
|
153
196
|
|
package/dist/client.js
CHANGED
|
@@ -101,6 +101,19 @@ export class MantisClient {
|
|
|
101
101
|
});
|
|
102
102
|
return this.handleResponse(response);
|
|
103
103
|
}
|
|
104
|
+
async postFormData(path, formData) {
|
|
105
|
+
// Note: Content-Type must NOT be set here — fetch sets it automatically
|
|
106
|
+
// with the correct multipart/form-data boundary.
|
|
107
|
+
const response = await fetch(this.buildUrl(path), {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: {
|
|
110
|
+
'Authorization': this.apiKey,
|
|
111
|
+
'Accept': 'application/json',
|
|
112
|
+
},
|
|
113
|
+
body: formData,
|
|
114
|
+
});
|
|
115
|
+
return this.handleResponse(response);
|
|
116
|
+
}
|
|
104
117
|
async getVersion() {
|
|
105
118
|
const response = await fetch(this.buildUrl('users/me'), {
|
|
106
119
|
method: 'GET',
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// .env.local loader
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
async function loadDotEnvLocal() {
|
|
9
|
+
try {
|
|
10
|
+
const envPath = join(dirname(fileURLToPath(import.meta.url)), '..', '.env.local');
|
|
11
|
+
const content = await readFile(envPath, 'utf-8');
|
|
12
|
+
for (const line of content.split('\n')) {
|
|
13
|
+
const match = line.match(/^([^#=\s][^=]*)=(.*)/);
|
|
14
|
+
if (match) {
|
|
15
|
+
const key = match[1].trim();
|
|
16
|
+
const value = match[2].trim().replace(/^["']|["']$/g, '');
|
|
17
|
+
if (!process.env[key])
|
|
18
|
+
process.env[key] = value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// .env.local not present — use environment variables directly
|
|
24
|
+
}
|
|
25
|
+
}
|
|
4
26
|
// ---------------------------------------------------------------------------
|
|
5
27
|
// Loader
|
|
6
28
|
// ---------------------------------------------------------------------------
|
|
@@ -18,6 +40,7 @@ let cachedConfig = null;
|
|
|
18
40
|
export async function getConfig() {
|
|
19
41
|
if (cachedConfig)
|
|
20
42
|
return cachedConfig;
|
|
43
|
+
await loadDotEnvLocal();
|
|
21
44
|
let baseUrl = process.env.MANTIS_BASE_URL ?? '';
|
|
22
45
|
let apiKey = process.env.MANTIS_API_KEY ?? '';
|
|
23
46
|
// If env vars are missing, try ~/.claude/mantis.json as fallback
|
|
@@ -44,11 +67,26 @@ export async function getConfig() {
|
|
|
44
67
|
const cacheTtl = process.env.MANTIS_CACHE_TTL
|
|
45
68
|
? parseInt(process.env.MANTIS_CACHE_TTL, 10)
|
|
46
69
|
: 3600;
|
|
70
|
+
const searchEnabled = process.env.MANTIS_SEARCH_ENABLED === 'true';
|
|
71
|
+
const searchBackendRaw = process.env.MANTIS_SEARCH_BACKEND ?? 'vectra';
|
|
72
|
+
if (searchBackendRaw !== 'vectra' && searchBackendRaw !== 'sqlite-vec') {
|
|
73
|
+
process.stderr.write(`[mantisbt-config] Unknown MANTIS_SEARCH_BACKEND="${searchBackendRaw}", falling back to "vectra"\n`);
|
|
74
|
+
}
|
|
75
|
+
const searchBackend = searchBackendRaw === 'sqlite-vec' ? 'sqlite-vec' : 'vectra';
|
|
76
|
+
const searchDir = process.env.MANTIS_SEARCH_DIR ?? join(cacheDir, 'search');
|
|
77
|
+
const searchModelName = process.env.MANTIS_SEARCH_MODEL ??
|
|
78
|
+
'Xenova/paraphrase-multilingual-MiniLM-L12-v2';
|
|
47
79
|
cachedConfig = {
|
|
48
80
|
baseUrl: baseUrl.replace(/\/$/, ''), // strip trailing slash
|
|
49
81
|
apiKey,
|
|
50
82
|
cacheDir,
|
|
51
83
|
cacheTtl,
|
|
84
|
+
search: {
|
|
85
|
+
enabled: searchEnabled,
|
|
86
|
+
backend: searchBackend,
|
|
87
|
+
dir: searchDir,
|
|
88
|
+
modelName: searchModelName,
|
|
89
|
+
},
|
|
52
90
|
};
|
|
53
91
|
return cachedConfig;
|
|
54
92
|
}
|
package/dist/index.js
CHANGED
|
@@ -50,10 +50,15 @@ async function createMcpServer() {
|
|
|
50
50
|
registerProjectTools(server, client);
|
|
51
51
|
registerUserTools(server, client);
|
|
52
52
|
registerFilterTools(server, client);
|
|
53
|
-
registerConfigTools(server, client);
|
|
53
|
+
registerConfigTools(server, client, cache);
|
|
54
54
|
registerMetadataTools(server, client, cache);
|
|
55
55
|
registerTagTools(server, client);
|
|
56
|
-
registerVersionTools(server, client, versionHint);
|
|
56
|
+
registerVersionTools(server, client, versionHint, version);
|
|
57
|
+
// Optional: Semantic search module
|
|
58
|
+
if (config.search.enabled) {
|
|
59
|
+
const { initializeSearchModule } = await import('./search/index.js');
|
|
60
|
+
await initializeSearchModule(server, client, config.search);
|
|
61
|
+
}
|
|
57
62
|
return server;
|
|
58
63
|
}
|
|
59
64
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Embedder
|
|
3
|
+
// Wraps @huggingface/transformers for local ONNX-based embeddings.
|
|
4
|
+
// Lazy-loaded on first use — no model download at server startup.
|
|
5
|
+
//
|
|
6
|
+
// @huggingface/transformers is an optional dependency. The import is
|
|
7
|
+
// guarded with a try/catch so the server starts without it.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
export class Embedder {
|
|
10
|
+
modelName;
|
|
11
|
+
pipe = null;
|
|
12
|
+
constructor(modelName) {
|
|
13
|
+
this.modelName = modelName;
|
|
14
|
+
}
|
|
15
|
+
async load() {
|
|
16
|
+
if (this.pipe)
|
|
17
|
+
return this.pipe;
|
|
18
|
+
process.stderr.write(`[mantisbt-search] Loading embedding model ${this.modelName}...\n`);
|
|
19
|
+
let transformers;
|
|
20
|
+
try {
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
22
|
+
transformers = (await import('@huggingface/transformers'));
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
26
|
+
throw new Error(`Failed to load @huggingface/transformers: ${msg}`);
|
|
27
|
+
}
|
|
28
|
+
this.pipe = await transformers.pipeline('feature-extraction', this.modelName);
|
|
29
|
+
return this.pipe;
|
|
30
|
+
}
|
|
31
|
+
async embed(text) {
|
|
32
|
+
const extractor = await this.load();
|
|
33
|
+
const output = await extractor(text, { pooling: 'mean', normalize: true });
|
|
34
|
+
return extractVector(output);
|
|
35
|
+
}
|
|
36
|
+
async embedBatch(texts) {
|
|
37
|
+
if (texts.length === 0)
|
|
38
|
+
return [];
|
|
39
|
+
const extractor = await this.load();
|
|
40
|
+
const output = await extractor(texts, { pooling: 'mean', normalize: true });
|
|
41
|
+
if (Array.isArray(output)) {
|
|
42
|
+
return output.map(extractVector);
|
|
43
|
+
}
|
|
44
|
+
// Some versions return a single tensor for batch input
|
|
45
|
+
return extractBatchVectors(output, texts.length);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Helpers
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
function extractVector(output) {
|
|
52
|
+
const data = output.data;
|
|
53
|
+
return Array.from(data);
|
|
54
|
+
}
|
|
55
|
+
function extractBatchVectors(output, batchSize) {
|
|
56
|
+
const data = Array.from(output.data);
|
|
57
|
+
// dims is typically [batchSize, sequenceLen, hiddenSize] or [batchSize, hiddenSize]
|
|
58
|
+
const vecSize = data.length / batchSize;
|
|
59
|
+
if (!Number.isInteger(vecSize)) {
|
|
60
|
+
throw new Error(`Unexpected batch output shape: ${data.length} elements for batch size ${batchSize}`);
|
|
61
|
+
}
|
|
62
|
+
const result = [];
|
|
63
|
+
for (let i = 0; i < batchSize; i++) {
|
|
64
|
+
result.push(data.slice(i * vecSize, (i + 1) * vecSize));
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createVectorStore } from './store.js';
|
|
2
|
+
import { Embedder } from './embedder.js';
|
|
3
|
+
import { registerSearchTools } from './tools.js';
|
|
4
|
+
import { SearchSyncService } from './sync.js';
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// initializeSearchModule
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
export async function initializeSearchModule(server, client, config) {
|
|
9
|
+
if (!config.enabled)
|
|
10
|
+
return;
|
|
11
|
+
const store = createVectorStore(config.backend, config.dir);
|
|
12
|
+
const embedder = new Embedder(config.modelName);
|
|
13
|
+
registerSearchTools(server, client, store, embedder);
|
|
14
|
+
// Pre-initialize lastKnownTotal so get_search_index_status shows a value
|
|
15
|
+
// immediately on startup, even while the background sync is still running.
|
|
16
|
+
const [startupCount, startupTotal] = await Promise.all([store.count(), store.getLastKnownTotal()]);
|
|
17
|
+
if (startupTotal === null && startupCount > 0) {
|
|
18
|
+
await store.setLastKnownTotal(startupCount);
|
|
19
|
+
}
|
|
20
|
+
// Non-blocking background sync on startup
|
|
21
|
+
const syncService = new SearchSyncService(client, store, embedder);
|
|
22
|
+
syncService.sync().catch((err) => {
|
|
23
|
+
console.error('[mantisbt-search] sync error:', err instanceof Error ? err.message : String(err));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// VectraStore
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
export class VectraStore {
|
|
7
|
+
dir;
|
|
8
|
+
vectraDir;
|
|
9
|
+
lastSyncFile;
|
|
10
|
+
lastTotalFile;
|
|
11
|
+
items = new Map();
|
|
12
|
+
loaded = false;
|
|
13
|
+
constructor(dir) {
|
|
14
|
+
this.dir = dir;
|
|
15
|
+
this.vectraDir = join(dir, 'vectra');
|
|
16
|
+
this.lastSyncFile = join(dir, 'last_sync.txt');
|
|
17
|
+
this.lastTotalFile = join(dir, 'last_total.txt');
|
|
18
|
+
}
|
|
19
|
+
async ensureLoaded() {
|
|
20
|
+
if (this.loaded)
|
|
21
|
+
return;
|
|
22
|
+
await mkdir(this.vectraDir, { recursive: true });
|
|
23
|
+
const indexFile = join(this.vectraDir, 'index.json');
|
|
24
|
+
try {
|
|
25
|
+
const raw = await readFile(indexFile, 'utf-8');
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
this.items = new Map(parsed.map(item => [item.id, item]));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// No index yet — start empty
|
|
31
|
+
this.items = new Map();
|
|
32
|
+
}
|
|
33
|
+
this.loaded = true;
|
|
34
|
+
}
|
|
35
|
+
async persist() {
|
|
36
|
+
const indexFile = join(this.vectraDir, 'index.json');
|
|
37
|
+
const data = JSON.stringify([...this.items.values()]);
|
|
38
|
+
await writeFile(indexFile, data, 'utf-8');
|
|
39
|
+
}
|
|
40
|
+
async add(item) {
|
|
41
|
+
await this.ensureLoaded();
|
|
42
|
+
this.items.set(item.id, item);
|
|
43
|
+
await this.persist();
|
|
44
|
+
}
|
|
45
|
+
async addBatch(items) {
|
|
46
|
+
await this.ensureLoaded();
|
|
47
|
+
for (const item of items) {
|
|
48
|
+
this.items.set(item.id, item);
|
|
49
|
+
}
|
|
50
|
+
await this.persist();
|
|
51
|
+
}
|
|
52
|
+
async search(vector, topN) {
|
|
53
|
+
await this.ensureLoaded();
|
|
54
|
+
const results = [];
|
|
55
|
+
for (const item of this.items.values()) {
|
|
56
|
+
const score = cosineSimilarity(vector, item.vector);
|
|
57
|
+
results.push({ id: item.id, score });
|
|
58
|
+
}
|
|
59
|
+
results.sort((a, b) => b.score - a.score);
|
|
60
|
+
return results.slice(0, topN);
|
|
61
|
+
}
|
|
62
|
+
async delete(id) {
|
|
63
|
+
await this.ensureLoaded();
|
|
64
|
+
this.items.delete(id);
|
|
65
|
+
await this.persist();
|
|
66
|
+
}
|
|
67
|
+
async count() {
|
|
68
|
+
await this.ensureLoaded();
|
|
69
|
+
return this.items.size;
|
|
70
|
+
}
|
|
71
|
+
async clear() {
|
|
72
|
+
await this.ensureLoaded();
|
|
73
|
+
this.items.clear();
|
|
74
|
+
await this.persist();
|
|
75
|
+
}
|
|
76
|
+
async getLastSyncedAt() {
|
|
77
|
+
try {
|
|
78
|
+
const content = await readFile(this.lastSyncFile, 'utf-8');
|
|
79
|
+
return content.trim() || null;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async setLastSyncedAt(ts) {
|
|
86
|
+
await mkdir(this.dir, { recursive: true });
|
|
87
|
+
await writeFile(this.lastSyncFile, ts, 'utf-8');
|
|
88
|
+
}
|
|
89
|
+
async resetLastSyncedAt() {
|
|
90
|
+
try {
|
|
91
|
+
await unlink(this.lastSyncFile);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (err.code !== 'ENOENT')
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async getLastKnownTotal() {
|
|
99
|
+
try {
|
|
100
|
+
const content = await readFile(this.lastTotalFile, 'utf-8');
|
|
101
|
+
const parsed = parseInt(content.trim(), 10);
|
|
102
|
+
return isNaN(parsed) ? null : parsed;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async setLastKnownTotal(total) {
|
|
109
|
+
await mkdir(this.dir, { recursive: true });
|
|
110
|
+
await writeFile(this.lastTotalFile, String(total), 'utf-8');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Cosine similarity helper
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
function cosineSimilarity(a, b) {
|
|
117
|
+
if (a.length !== b.length || a.length === 0)
|
|
118
|
+
return 0;
|
|
119
|
+
let dot = 0;
|
|
120
|
+
let normA = 0;
|
|
121
|
+
let normB = 0;
|
|
122
|
+
for (let i = 0; i < a.length; i++) {
|
|
123
|
+
dot += a[i] * b[i];
|
|
124
|
+
normA += a[i] * a[i];
|
|
125
|
+
normB += b[i] * b[i];
|
|
126
|
+
}
|
|
127
|
+
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
128
|
+
return denom === 0 ? 0 : dot / denom;
|
|
129
|
+
}
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Factory
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
export function createVectorStore(backend, dir) {
|
|
134
|
+
if (backend === 'sqlite-vec') {
|
|
135
|
+
throw new Error('sqlite-vec backend requires manual installation: npm install sqlite-vec better-sqlite3');
|
|
136
|
+
}
|
|
137
|
+
return new VectraStore(dir);
|
|
138
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// SearchSyncService
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
const PAGE_SIZE = 50;
|
|
5
|
+
const EMBED_BATCH_SIZE = 10;
|
|
6
|
+
export class SearchSyncService {
|
|
7
|
+
client;
|
|
8
|
+
store;
|
|
9
|
+
embedder;
|
|
10
|
+
constructor(client, store, embedder) {
|
|
11
|
+
this.client = client;
|
|
12
|
+
this.store = store;
|
|
13
|
+
this.embedder = embedder;
|
|
14
|
+
}
|
|
15
|
+
async sync(projectId) {
|
|
16
|
+
const lastSyncedAt = await this.store.getLastSyncedAt();
|
|
17
|
+
const { issues: allIssues, totalFromApi } = await this.fetchAllIssues(lastSyncedAt ?? undefined, projectId);
|
|
18
|
+
let indexed = 0;
|
|
19
|
+
let skipped = 0;
|
|
20
|
+
// Process in batches of EMBED_BATCH_SIZE
|
|
21
|
+
for (let i = 0; i < allIssues.length; i += EMBED_BATCH_SIZE) {
|
|
22
|
+
const batch = allIssues.slice(i, i + EMBED_BATCH_SIZE);
|
|
23
|
+
const toEmbed = [];
|
|
24
|
+
for (const issue of batch) {
|
|
25
|
+
if (!issue.summary) {
|
|
26
|
+
skipped++;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const text = `${issue.summary}\n${issue.description ?? ''}`.trim();
|
|
30
|
+
toEmbed.push({ issue, text });
|
|
31
|
+
}
|
|
32
|
+
if (toEmbed.length === 0)
|
|
33
|
+
continue;
|
|
34
|
+
const vectors = await this.embedder.embedBatch(toEmbed.map(e => e.text));
|
|
35
|
+
const batchItems = toEmbed.map((e, j) => ({
|
|
36
|
+
id: e.issue.id,
|
|
37
|
+
vector: vectors[j],
|
|
38
|
+
metadata: {
|
|
39
|
+
summary: e.issue.summary,
|
|
40
|
+
description: e.issue.description,
|
|
41
|
+
updated_at: e.issue.updated_at,
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
await this.store.addBatch(batchItems);
|
|
45
|
+
indexed += batchItems.length;
|
|
46
|
+
}
|
|
47
|
+
await this.store.setLastSyncedAt(new Date().toISOString());
|
|
48
|
+
// Persist the best known total for get_search_index_status.
|
|
49
|
+
// Priority: API total_count > full-rebuild count > current store size.
|
|
50
|
+
// The store size fallback handles MantisBT installations that never return
|
|
51
|
+
// total_count and ensures the status tool never shows "total unknown" after
|
|
52
|
+
// any sync has completed.
|
|
53
|
+
const storeCount = await this.store.count();
|
|
54
|
+
const total = totalFromApi ?? (lastSyncedAt === null ? indexed + skipped : storeCount);
|
|
55
|
+
await this.store.setLastKnownTotal(total);
|
|
56
|
+
return { indexed, skipped, total };
|
|
57
|
+
}
|
|
58
|
+
async fetchAllIssues(updatedAfter, projectId) {
|
|
59
|
+
const allIssues = [];
|
|
60
|
+
let totalFromApi = null;
|
|
61
|
+
let page = 1;
|
|
62
|
+
while (true) {
|
|
63
|
+
const params = {
|
|
64
|
+
page_size: PAGE_SIZE,
|
|
65
|
+
page,
|
|
66
|
+
sort: 'updated_at',
|
|
67
|
+
direction: 'DESC',
|
|
68
|
+
select: 'id,summary,description,updated_at',
|
|
69
|
+
};
|
|
70
|
+
if (projectId !== undefined) {
|
|
71
|
+
params.project_id = projectId;
|
|
72
|
+
}
|
|
73
|
+
if (updatedAfter) {
|
|
74
|
+
params.updated_after = updatedAfter;
|
|
75
|
+
}
|
|
76
|
+
const response = await this.client.get('issues', params);
|
|
77
|
+
const pageIssues = response.issues ?? [];
|
|
78
|
+
allIssues.push(...pageIssues);
|
|
79
|
+
// Capture total_count from the first page that provides it
|
|
80
|
+
if (totalFromApi === null && response.total_count != null) {
|
|
81
|
+
totalFromApi = response.total_count;
|
|
82
|
+
}
|
|
83
|
+
// Stop when we have fetched all issues:
|
|
84
|
+
// - total_count is provided and reached, or
|
|
85
|
+
// - page returned fewer items than requested (last page)
|
|
86
|
+
if (totalFromApi !== null) {
|
|
87
|
+
if (allIssues.length >= totalFromApi)
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
else if (pageIssues.length < PAGE_SIZE) {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
page++;
|
|
94
|
+
}
|
|
95
|
+
return { issues: allIssues, totalFromApi };
|
|
96
|
+
}
|
|
97
|
+
}
|