@dpesch/mantisbt-mcp-server 1.2.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.de.md +25 -4
  3. package/README.md +25 -4
  4. package/dist/index.js +2 -2
  5. package/dist/search/index.js +6 -0
  6. package/dist/search/store.js +16 -0
  7. package/dist/search/sync.js +18 -6
  8. package/dist/search/tools.js +53 -6
  9. package/dist/tools/config.js +106 -10
  10. package/dist/tools/files.js +2 -2
  11. package/dist/tools/issues.js +13 -14
  12. package/dist/tools/metadata.js +56 -17
  13. package/dist/tools/monitors.js +2 -2
  14. package/dist/tools/notes.js +4 -4
  15. package/dist/tools/projects.js +13 -6
  16. package/dist/tools/relationships.js +5 -5
  17. package/dist/tools/tags.js +4 -4
  18. package/dist/tools/version.js +16 -1
  19. package/package.json +1 -1
  20. package/scripts/record-fixtures.ts +1 -1
  21. package/tests/cache.test.ts +1 -0
  22. package/tests/fixtures/get_current_user.json +6 -5
  23. package/tests/fixtures/get_issue.json +43 -83
  24. package/tests/fixtures/get_issue_fields_sample.json +17 -51
  25. package/tests/fixtures/get_project_categories.json +99 -2
  26. package/tests/fixtures/get_project_versions_with_data.json +35 -11
  27. package/tests/fixtures/list_issues.json +51 -57
  28. package/tests/fixtures/list_projects.json +45 -45
  29. package/tests/helpers/mock-server.ts +38 -4
  30. package/tests/helpers/search-mocks.ts +3 -1
  31. package/tests/search/sync.test.ts +50 -0
  32. package/tests/search/tools.test.ts +97 -0
  33. package/tests/tools/config.test.ts +97 -0
  34. package/tests/tools/issues.test.ts +51 -4
  35. package/tests/tools/metadata.test.ts +122 -0
  36. package/tests/tools/projects.test.ts +31 -0
  37. package/tests/tools/string-coercion.test.ts +251 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ 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
+
10
30
  ## [1.2.0] – 2026-03-16
11
31
 
12
32
  ### Added
package/README.de.md CHANGED
@@ -130,7 +130,7 @@ Falls keine Umgebungsvariablen gesetzt sind, wird `~/.claude/mantis.json` ausgel
130
130
 
131
131
  | Tool | Beschreibung |
132
132
  |---|---|
133
- | `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) |
134
134
  | `attach_tags` | Tags an ein Issue hängen |
135
135
  | `detach_tag` | Tag von einem Issue entfernen |
136
136
 
@@ -139,20 +139,39 @@ Falls keine Umgebungsvariablen gesetzt sind, wird `~/.claude/mantis.json` ausgel
139
139
  | Tool | Beschreibung |
140
140
  |---|---|
141
141
  | `list_projects` | Alle zugänglichen Projekte auflisten |
142
- | `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 |
143
143
  | `get_project_categories` | Kategorien eines Projekts abrufen |
144
144
  | `get_project_users` | Benutzer eines Projekts abrufen |
145
145
 
146
146
  ### Semantische Suche *(optional)*
147
147
 
148
- Aktivierung mit `MANTIS_SEARCH_ENABLED=true`. Beim ersten Start wird das Embedding-Modell (~80 MB) heruntergeladen und lokal gecacht. Alle Issues werden danach bei jedem Serverstart inkrementell indexiert.
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`.
149
157
 
150
158
  | Tool | Beschreibung |
151
159
  |---|---|
152
160
  | `search_issues` | Natürlichsprachige Suche über alle indizierten Issues – liefert Top-N-Ergebnisse mit Cosine-Similarity-Score |
153
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.
154
174
 
155
- **`sqlite-vec`-Backend** (optional, schneller bei großen Instanzen):
156
175
  ```bash
157
176
  npm install sqlite-vec better-sqlite3
158
177
  # dann MANTIS_SEARCH_BACKEND=sqlite-vec setzen
@@ -169,7 +188,9 @@ npm install sqlite-vec better-sqlite3
169
188
  | `get_current_user` | Eigenes Benutzerprofil abrufen |
170
189
  | `list_languages` | Verfügbare Sprachen auflisten |
171
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 |
172
192
  | `get_mantis_version` | MantisBT-Version abrufen und auf Updates prüfen |
193
+ | `get_mcp_version` | Version dieser mantisbt-mcp-server-Instanz zurückgeben |
173
194
 
174
195
  ## HTTP-Modus
175
196
 
package/README.md CHANGED
@@ -130,7 +130,7 @@ If no environment variables are set, `~/.claude/mantis.json` is read:
130
130
 
131
131
  | Tool | Description |
132
132
  |---|---|
133
- | `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) |
134
134
  | `attach_tags` | Attach tags to an issue |
135
135
  | `detach_tag` | Remove a tag from an issue |
136
136
 
@@ -139,20 +139,39 @@ If no environment variables are set, `~/.claude/mantis.json` is read:
139
139
  | Tool | Description |
140
140
  |---|---|
141
141
  | `list_projects` | List all accessible projects |
142
- | `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 |
143
143
  | `get_project_categories` | Get categories of a project |
144
144
  | `get_project_users` | Get users of a project |
145
145
 
146
146
  ### Semantic search *(optional)*
147
147
 
148
- Activate with `MANTIS_SEARCH_ENABLED=true`. On first start the embedding model (~80 MB) is downloaded and cached locally. All issues are then indexed incrementally on each server start.
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`.
149
157
 
150
158
  | Tool | Description |
151
159
  |---|---|
152
160
  | `search_issues` | Natural language search over all indexed issues — returns top-N results with cosine similarity score |
153
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.
154
174
 
155
- **`sqlite-vec` backend** (optional, faster for large instances):
156
175
  ```bash
157
176
  npm install sqlite-vec better-sqlite3
158
177
  # then set MANTIS_SEARCH_BACKEND=sqlite-vec
@@ -169,7 +188,9 @@ npm install sqlite-vec better-sqlite3
169
188
  | `get_current_user` | Retrieve your own user profile |
170
189
  | `list_languages` | List available languages |
171
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 |
172
192
  | `get_mantis_version` | Get MantisBT version and check for updates |
193
+ | `get_mcp_version` | Return the version of this mantisbt-mcp-server instance |
173
194
 
174
195
  ## HTTP mode
175
196
 
package/dist/index.js CHANGED
@@ -50,10 +50,10 @@ 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
57
  // Optional: Semantic search module
58
58
  if (config.search.enabled) {
59
59
  const { initializeSearchModule } = await import('./search/index.js');
@@ -11,6 +11,12 @@ export async function initializeSearchModule(server, client, config) {
11
11
  const store = createVectorStore(config.backend, config.dir);
12
12
  const embedder = new Embedder(config.modelName);
13
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
+ }
14
20
  // Non-blocking background sync on startup
15
21
  const syncService = new SearchSyncService(client, store, embedder);
16
22
  syncService.sync().catch((err) => {
@@ -7,12 +7,14 @@ export class VectraStore {
7
7
  dir;
8
8
  vectraDir;
9
9
  lastSyncFile;
10
+ lastTotalFile;
10
11
  items = new Map();
11
12
  loaded = false;
12
13
  constructor(dir) {
13
14
  this.dir = dir;
14
15
  this.vectraDir = join(dir, 'vectra');
15
16
  this.lastSyncFile = join(dir, 'last_sync.txt');
17
+ this.lastTotalFile = join(dir, 'last_total.txt');
16
18
  }
17
19
  async ensureLoaded() {
18
20
  if (this.loaded)
@@ -93,6 +95,20 @@ export class VectraStore {
93
95
  throw err;
94
96
  }
95
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
+ }
96
112
  }
97
113
  // ---------------------------------------------------------------------------
98
114
  // Cosine similarity helper
@@ -14,7 +14,7 @@ export class SearchSyncService {
14
14
  }
15
15
  async sync(projectId) {
16
16
  const lastSyncedAt = await this.store.getLastSyncedAt();
17
- const allIssues = await this.fetchAllIssues(lastSyncedAt ?? undefined, projectId);
17
+ const { issues: allIssues, totalFromApi } = await this.fetchAllIssues(lastSyncedAt ?? undefined, projectId);
18
18
  let indexed = 0;
19
19
  let skipped = 0;
20
20
  // Process in batches of EMBED_BATCH_SIZE
@@ -45,10 +45,19 @@ export class SearchSyncService {
45
45
  indexed += batchItems.length;
46
46
  }
47
47
  await this.store.setLastSyncedAt(new Date().toISOString());
48
- return { indexed, skipped };
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 };
49
57
  }
50
58
  async fetchAllIssues(updatedAfter, projectId) {
51
59
  const allIssues = [];
60
+ let totalFromApi = null;
52
61
  let page = 1;
53
62
  while (true) {
54
63
  const params = {
@@ -67,12 +76,15 @@ export class SearchSyncService {
67
76
  const response = await this.client.get('issues', params);
68
77
  const pageIssues = response.issues ?? [];
69
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
+ }
70
83
  // Stop when we have fetched all issues:
71
84
  // - total_count is provided and reached, or
72
85
  // - page returned fewer items than requested (last page)
73
- const total = response.total_count;
74
- if (total !== undefined && total !== null) {
75
- if (allIssues.length >= total)
86
+ if (totalFromApi !== null) {
87
+ if (allIssues.length >= totalFromApi)
76
88
  break;
77
89
  }
78
90
  else if (pageIssues.length < PAGE_SIZE) {
@@ -80,6 +92,6 @@ export class SearchSyncService {
80
92
  }
81
93
  page++;
82
94
  }
83
- return allIssues;
95
+ return { issues: allIssues, totalFromApi };
84
96
  }
85
97
  }
@@ -1,5 +1,12 @@
1
1
  import { z } from 'zod';
2
2
  import { SearchSyncService } from './sync.js';
3
+ import { getVersionHint } from '../version-hint.js';
4
+ function errorText(msg) {
5
+ const vh = getVersionHint();
6
+ vh?.triggerLatestVersionFetch();
7
+ const hint = vh?.getUpdateHint();
8
+ return hint ? `Error: ${msg}\n\n${hint}` : `Error: ${msg}`;
9
+ }
3
10
  // ---------------------------------------------------------------------------
4
11
  // registerSearchTools
5
12
  // ---------------------------------------------------------------------------
@@ -14,7 +21,7 @@ export function registerSearchTools(server, client, store, embedder) {
14
21
  inputSchema: z.object({
15
22
  query: z.string().describe('Natural language search query'),
16
23
  top_n: z
17
- .number()
24
+ .coerce.number()
18
25
  .int()
19
26
  .positive()
20
27
  .max(50)
@@ -48,7 +55,47 @@ export function registerSearchTools(server, client, store, embedder) {
48
55
  }
49
56
  catch (error) {
50
57
  const msg = error instanceof Error ? error.message : String(error);
51
- return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
58
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
59
+ }
60
+ });
61
+ // ---------------------------------------------------------------------------
62
+ // get_search_index_status
63
+ // ---------------------------------------------------------------------------
64
+ server.registerTool('get_search_index_status', {
65
+ title: 'Search Index Status',
66
+ description: 'Returns the current fill level of the semantic search index: how many issues are ' +
67
+ 'indexed vs. the total number of issues in MantisBT, plus the timestamp of the last sync.',
68
+ inputSchema: z.object({}),
69
+ annotations: {
70
+ readOnlyHint: true,
71
+ destructiveHint: false,
72
+ idempotentHint: true,
73
+ },
74
+ }, async () => {
75
+ try {
76
+ const [indexed, lastSyncedAt, total] = await Promise.all([
77
+ store.count(),
78
+ store.getLastSyncedAt(),
79
+ store.getLastKnownTotal(),
80
+ ]);
81
+ const percent = total !== null
82
+ ? (total > 0 ? Math.round((indexed / total) * 100) : 0)
83
+ : null;
84
+ const summary = total !== null
85
+ ? `${indexed}/${total} (${percent} %)`
86
+ : `${indexed}/? (total unknown)`;
87
+ return {
88
+ content: [
89
+ {
90
+ type: 'text',
91
+ text: JSON.stringify({ summary, indexed, total, percent, last_synced_at: lastSyncedAt }, null, 2),
92
+ },
93
+ ],
94
+ };
95
+ }
96
+ catch (error) {
97
+ const msg = error instanceof Error ? error.message : String(error);
98
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
52
99
  }
53
100
  });
54
101
  // ---------------------------------------------------------------------------
@@ -60,7 +107,7 @@ export function registerSearchTools(server, client, store, embedder) {
60
107
  'Use full: true to clear the existing index and rebuild from scratch.',
61
108
  inputSchema: z.object({
62
109
  project_id: z
63
- .number()
110
+ .coerce.number()
64
111
  .int()
65
112
  .positive()
66
113
  .optional()
@@ -83,20 +130,20 @@ export function registerSearchTools(server, client, store, embedder) {
83
130
  }
84
131
  const startMs = Date.now();
85
132
  const syncService = new SearchSyncService(client, store, embedder);
86
- const { indexed, skipped } = await syncService.sync(project_id);
133
+ const { indexed, skipped, total } = await syncService.sync(project_id);
87
134
  const duration_ms = Date.now() - startMs;
88
135
  return {
89
136
  content: [
90
137
  {
91
138
  type: 'text',
92
- text: JSON.stringify({ indexed, skipped, duration_ms }, null, 2),
139
+ text: JSON.stringify({ indexed, skipped, total, duration_ms }, null, 2),
93
140
  },
94
141
  ],
95
142
  };
96
143
  }
97
144
  catch (error) {
98
145
  const msg = error instanceof Error ? error.message : String(error);
99
- return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
146
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
100
147
  }
101
148
  });
102
149
  }
@@ -6,7 +6,21 @@ function errorText(msg) {
6
6
  const hint = vh?.getUpdateHint();
7
7
  return hint ? `Error: ${msg}\n\n${hint}` : `Error: ${msg}`;
8
8
  }
9
- export function registerConfigTools(server, client) {
9
+ // Parse a MantisBT enum string ("10:feature,20:trivial,...") into {id, name}[]
10
+ function parseEnumString(raw) {
11
+ return raw
12
+ .split(',')
13
+ .map((entry) => {
14
+ const colonIdx = entry.indexOf(':');
15
+ if (colonIdx === -1)
16
+ return null;
17
+ const id = parseInt(entry.slice(0, colonIdx), 10);
18
+ const name = entry.slice(colonIdx + 1).trim();
19
+ return isNaN(id) ? null : { id, name };
20
+ })
21
+ .filter((e) => e !== null);
22
+ }
23
+ export function registerConfigTools(server, client, cache) {
10
24
  // ---------------------------------------------------------------------------
11
25
  // get_config
12
26
  // ---------------------------------------------------------------------------
@@ -47,6 +61,70 @@ Common option names:
47
61
  }
48
62
  });
49
63
  // ---------------------------------------------------------------------------
64
+ // get_issue_enums
65
+ // ---------------------------------------------------------------------------
66
+ server.registerTool('get_issue_enums', {
67
+ title: 'Get Issue Enum Values',
68
+ description: `Return valid ID and name pairs for all issue enum fields.
69
+
70
+ Use this tool before creating or updating issues to look up the correct value
71
+ for severity, status, priority, resolution, or reproducibility.
72
+
73
+ Example response:
74
+ {
75
+ "severity": [{"id": 10, "name": "feature"}, {"id": 50, "name": "minor"}, ...],
76
+ "status": [{"id": 10, "name": "new"}, {"id": 20, "name": "feedback"}, ...],
77
+ "priority": [{"id": 10, "name": "none"}, {"id": 30, "name": "normal"}, ...],
78
+ "resolution": [{"id": 10, "name": "open"}, {"id": 20, "name": "fixed"}, ...],
79
+ "reproducibility": [{"id": 10, "name": "always"}, {"id": 70, "name": "have not tried"}, ...]
80
+ }
81
+
82
+ The "name" field is the value to pass to create_issue or update_issue.`,
83
+ inputSchema: z.object({}),
84
+ annotations: {
85
+ readOnlyHint: true,
86
+ destructiveHint: false,
87
+ idempotentHint: true,
88
+ },
89
+ }, async () => {
90
+ try {
91
+ const enumOptions = [
92
+ 'severity_enum_string',
93
+ 'status_enum_string',
94
+ 'priority_enum_string',
95
+ 'resolution_enum_string',
96
+ 'reproducibility_enum_string',
97
+ ];
98
+ const params = {};
99
+ enumOptions.forEach((opt, i) => {
100
+ params[`option[${i}]`] = opt;
101
+ });
102
+ const result = await client.get('config', params);
103
+ const configs = result.configs ?? [];
104
+ const keyMap = {
105
+ severity_enum_string: 'severity',
106
+ status_enum_string: 'status',
107
+ priority_enum_string: 'priority',
108
+ resolution_enum_string: 'resolution',
109
+ reproducibility_enum_string: 'reproducibility',
110
+ };
111
+ const enums = {};
112
+ for (const { option, value } of configs) {
113
+ const key = keyMap[option];
114
+ if (key && typeof value === 'string') {
115
+ enums[key] = parseEnumString(value);
116
+ }
117
+ }
118
+ return {
119
+ content: [{ type: 'text', text: JSON.stringify(enums, null, 2) }],
120
+ };
121
+ }
122
+ catch (error) {
123
+ const msg = error instanceof Error ? error.message : String(error);
124
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
125
+ }
126
+ });
127
+ // ---------------------------------------------------------------------------
50
128
  // list_languages
51
129
  // ---------------------------------------------------------------------------
52
130
  server.registerTool('list_languages', {
@@ -77,12 +155,12 @@ Common option names:
77
155
  title: 'List Tags',
78
156
  description: `List all tags defined in the MantisBT installation.
79
157
 
80
- Note: The GET /tags endpoint is not available in MantisBT 2.25 and earlier.
81
- If your MantisBT version does not support this endpoint, you will receive an error.
82
- In that case, use get_issue to read the tags of a specific issue instead.`,
158
+ The MantisBT REST API exposes a GET /tags endpoint on some installations.
159
+ If that endpoint is not available, this tool falls back to the local metadata
160
+ cache populated by sync_metadata.`,
83
161
  inputSchema: z.object({
84
- page: z.number().int().positive().default(1).describe('Page number (default: 1)'),
85
- page_size: z.number().int().min(1).max(200).default(50).describe('Tags per page (default: 50)'),
162
+ page: z.coerce.number().int().positive().default(1).describe('Page number (default: 1)'),
163
+ page_size: z.coerce.number().int().min(1).max(200).default(50).describe('Tags per page (default: 50)'),
86
164
  }),
87
165
  annotations: {
88
166
  readOnlyHint: true,
@@ -98,10 +176,28 @@ In that case, use get_issue to read the tags of a specific issue instead.`,
98
176
  }
99
177
  catch (error) {
100
178
  const msg = error instanceof Error ? error.message : String(error);
101
- const hint = msg.includes('404')
102
- ? `${msg}\n\nThe GET /tags endpoint is not supported by this MantisBT version. Use get_issue to read tags of a specific issue instead.`
103
- : msg;
104
- return { content: [{ type: 'text', text: `Error: ${hint}` }], isError: true };
179
+ if (msg.includes('404')) {
180
+ // GET /tags endpoint not available fall back to metadata cache
181
+ const metadata = await cache.load();
182
+ if (metadata && Array.isArray(metadata.tags) && metadata.tags.length > 0) {
183
+ const start = (page - 1) * page_size;
184
+ const paginated = metadata.tags.slice(start, start + page_size);
185
+ return {
186
+ content: [{
187
+ type: 'text',
188
+ text: JSON.stringify({ tags: paginated, source: 'cache' }, null, 2),
189
+ }],
190
+ };
191
+ }
192
+ return {
193
+ content: [{
194
+ type: 'text',
195
+ text: `Error: ${msg}\n\nThe GET /tags endpoint is not available in this MantisBT installation. No cached tags found either — run sync_metadata to populate the cache if your installation provides this endpoint.`,
196
+ }],
197
+ isError: true,
198
+ };
199
+ }
200
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
105
201
  }
106
202
  });
107
203
  }
@@ -16,7 +16,7 @@ export function registerFileTools(server, client) {
16
16
  title: 'List Issue File Attachments',
17
17
  description: 'List all file attachments of a MantisBT issue.',
18
18
  inputSchema: z.object({
19
- issue_id: z.number().int().positive().describe('Numeric issue ID'),
19
+ issue_id: z.coerce.number().int().positive().describe('Numeric issue ID'),
20
20
  }),
21
21
  annotations: {
22
22
  readOnlyHint: true,
@@ -49,7 +49,7 @@ Two input modes (exactly one must be provided):
49
49
 
50
50
  The optional content_type parameter sets the MIME type (e.g. "image/png"). If omitted, "application/octet-stream" is used.`,
51
51
  inputSchema: z.object({
52
- issue_id: z.number().int().positive().describe('Numeric issue ID'),
52
+ issue_id: z.coerce.number().int().positive().describe('Numeric issue ID'),
53
53
  file_path: z.string().min(1).optional().describe('Absolute path to the local file to upload (mutually exclusive with content)'),
54
54
  content: z.string().min(1).optional().describe('Base64-encoded file content (mutually exclusive with file_path)'),
55
55
  filename: z.string().min(1).optional().describe('File name for the attachment (required when using content; overrides the derived name when using file_path)'),
@@ -15,7 +15,7 @@ export function registerIssueTools(server, client) {
15
15
  title: 'Get Issue',
16
16
  description: 'Retrieve a single MantisBT issue by its numeric ID. Returns all issue fields including notes, attachments, and relationships.',
17
17
  inputSchema: z.object({
18
- id: z.number().int().positive().describe('Numeric issue ID'),
18
+ id: z.coerce.number().int().positive().describe('Numeric issue ID'),
19
19
  }),
20
20
  annotations: {
21
21
  readOnlyHint: true,
@@ -42,12 +42,12 @@ export function registerIssueTools(server, client) {
42
42
  title: 'List Issues',
43
43
  description: 'List MantisBT issues with optional filtering. Returns a paginated list of issues. Use the "select" parameter to limit returned fields and reduce response size significantly.',
44
44
  inputSchema: z.object({
45
- project_id: z.number().int().positive().optional().describe('Filter by project ID'),
46
- page: z.number().int().positive().default(1).describe('Page number (default: 1)'),
47
- page_size: z.number().int().min(1).max(50).default(50).describe('Issues per page (default: 50, max: 50)'),
48
- assigned_to: z.number().int().positive().optional().describe('Filter by handler/assignee user ID'),
49
- reporter_id: z.number().int().positive().optional().describe('Filter by reporter user ID'),
50
- filter_id: z.number().int().positive().optional().describe('Use a saved MantisBT filter ID'),
45
+ project_id: z.coerce.number().int().positive().optional().describe('Filter by project ID'),
46
+ page: z.coerce.number().int().positive().default(1).describe('Page number (default: 1)'),
47
+ page_size: z.coerce.number().int().min(1).max(50).default(50).describe('Issues per page (default: 50, max: 50)'),
48
+ assigned_to: z.coerce.number().int().positive().optional().describe('Filter by handler/assignee user ID'),
49
+ reporter_id: z.coerce.number().int().positive().optional().describe('Filter by reporter user ID'),
50
+ filter_id: z.coerce.number().int().positive().optional().describe('Use a saved MantisBT filter ID'),
51
51
  sort: z.string().optional().describe('Sort field (e.g. "last_updated", "id")'),
52
52
  direction: z.enum(['ASC', 'DESC']).optional().describe('Sort direction'),
53
53
  select: z.string().optional().describe('Comma-separated list of fields to include in the response (server-side projection). Significantly reduces response size. Example: "id,summary,status,priority,handler,updated_at"'),
@@ -100,11 +100,11 @@ export function registerIssueTools(server, client) {
100
100
  inputSchema: z.object({
101
101
  summary: z.string().min(1).describe('Issue summary/title'),
102
102
  description: z.string().default('').describe('Detailed issue description'),
103
- project_id: z.number().int().positive().describe('Project ID the issue belongs to'),
103
+ project_id: z.coerce.number().int().positive().describe('Project ID the issue belongs to'),
104
104
  category: z.string().min(1).describe('Category name (use get_project_categories to list available categories)'),
105
105
  priority: z.string().optional().describe('Priority name (e.g. "normal", "high", "urgent", "immediate", "low", "none")'),
106
- severity: z.string().optional().describe('Severity name (e.g. "minor", "major", "crash", "block", "feature", "trivial", "text")'),
107
- handler_id: z.number().int().positive().optional().describe('User ID of the person to assign the issue to'),
106
+ severity: z.string().default('minor').describe('Severity name (e.g. "minor", "major", "crash", "block", "feature", "trivial", "text") — default: "minor"'),
107
+ handler_id: z.coerce.number().int().positive().optional().describe('User ID of the person to assign the issue to'),
108
108
  }),
109
109
  annotations: {
110
110
  readOnlyHint: false,
@@ -121,8 +121,7 @@ export function registerIssueTools(server, client) {
121
121
  };
122
122
  if (priority)
123
123
  body.priority = { name: priority };
124
- if (severity)
125
- body.severity = { name: severity };
124
+ body.severity = { name: severity };
126
125
  if (handler_id)
127
126
  body.handler = { id: handler_id };
128
127
  const result = await client.post('issues', body);
@@ -156,7 +155,7 @@ The "fields" object accepts any combination of:
156
155
 
157
156
  Important: when resolving an issue, always set BOTH status and resolution to avoid leaving resolution as "open".`,
158
157
  inputSchema: z.object({
159
- id: z.number().int().positive().describe('Numeric issue ID to update'),
158
+ id: z.coerce.number().int().positive().describe('Numeric issue ID to update'),
160
159
  fields: z.record(z.unknown()).describe('Object containing the fields to update (partial update — only provided fields are changed)'),
161
160
  }),
162
161
  annotations: {
@@ -183,7 +182,7 @@ Important: when resolving an issue, always set BOTH status and resolution to avo
183
182
  title: 'Delete Issue',
184
183
  description: 'Permanently delete a MantisBT issue. This action is irreversible.',
185
184
  inputSchema: z.object({
186
- id: z.number().int().positive().describe('Numeric issue ID to delete'),
185
+ id: z.coerce.number().int().positive().describe('Numeric issue ID to delete'),
187
186
  }),
188
187
  annotations: {
189
188
  readOnlyHint: false,