@dpesch/mantisbt-mcp-server 1.8.2 → 1.9.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 CHANGED
@@ -11,6 +11,37 @@ This project adheres to [Semantic Versioning](https://semver.org/).
11
11
 
12
12
  ---
13
13
 
14
+ ## [1.9.0] – 2026-03-28
15
+
16
+ ### Added
17
+ - New tool `get_issues`: fetch multiple MantisBT issues by ID in a single MCP call. Requests run in parallel (max 5 concurrent). Missing or inaccessible IDs return `null` at their array position instead of failing the entire call. Response includes `requested`, `found`, and `failed` counters. Accepts 1–50 IDs per call.
18
+ - `update_issue` now accepts an optional `dry_run` parameter. When `dry_run: true`, the tool returns the patch payload that would be sent without actually updating the issue — useful for previewing changes before committing them.
19
+ - `list_issues` now accepts four date filter parameters: `updated_after`, `updated_before`, `created_after`, `created_before` (ISO-8601, exclusive). Filters are applied client-side with an early-exit optimisation: once a batch of results is fully older than `updated_after`, further pages are not fetched.
20
+ - `search_issues` now accepts the same four date filter parameters. Without `select`, filtering uses VectraStore metadata (no extra API calls). With `select`, filtering uses the already-fetched issue object.
21
+ - `search_issues` now accepts an optional `highlight` parameter (default: `false`). When `true`, each result gains a `highlights` field containing the issue summary with matching query terms wrapped in `**bold**` markdown, and a short description snippet (~300 chars) centered around the first match. Highlights are keyword-based (lexical), not semantic — results without a lexical overlap will not have a `highlights` field. Without `select`, highlights are built from the VectraStore metadata (no extra API calls); with `select`, from the fetched issue fields.
22
+ - `VectorStore` interface extended with `getItem(id)` to support metadata lookups without re-fetching from the API.
23
+ - New internal module `src/date-filter.ts` with shared `matchesDateFilter`, `hasDateFilter`, and `dateFilterSchema` — reused by both tools.
24
+ - New internal module `src/search/highlight.ts` with `extractTerms`, `highlightText`, `hasTermMatch`, and `extractSnippet` — used by `search_issues` highlighting.
25
+
26
+ ### Changed
27
+ - `get_issue` tool description now explicitly states that notes are always included in the response — no separate `list_notes` call needed.
28
+ - `list_notes` tool description now clarifies it is only needed when fetching notes without the full issue object.
29
+ - `create_issue` now accepts localized enum names for `severity`, `priority`, and `reproducibility` in addition to canonical English names. The server resolves the value against a static canonical table first; on a miss, it falls back to a live `get_issue_enums` lookup and matches against the `name` and `label` fields (case-insensitive). Only truly unknown values produce an error.
30
+ - `update_issue` now resolves enum names for `status`, `priority`, `severity`, `resolution`, and `reproducibility` before sending the PATCH request. Pass a canonical English name, a localized name, or a numeric `id` — all are accepted. Unknown names are passed through unchanged and validated by the MantisBT API.
31
+ - `list_issues` status filter now compares by ID when a canonical English status name is recognised (e.g. `"new"` → `id=10`). This makes the filter language-independent: it correctly matches issues on localized installations where the API returns translated status names. Non-canonical values (e.g. passing a localized name directly) fall back to name comparison as before.
32
+
33
+ ---
34
+
35
+ ## [1.8.3] – 2026-03-28
36
+
37
+ ### Added
38
+ - New optional env var `MCP_TEST_ENVIRONMENT=true`. When set, `resources/list` skips live API calls and returns only static resources. Intended for automated inspection environments (e.g. Glama Docker builds) that start the server with placeholder credentials and restricted network access.
39
+
40
+ ### Fixed
41
+ - All `MantisClient` fetch calls now use a 30-second timeout via `AbortSignal.timeout()`. Previously, a slow or unreachable MantisBT instance would cause fetch calls to hang indefinitely.
42
+
43
+ ---
44
+
14
45
  ## [1.8.2] – 2026-03-27
15
46
 
16
47
  ### Fixed
package/README.de.md CHANGED
@@ -87,9 +87,9 @@ npm run build
87
87
  | Tool | Beschreibung |
88
88
  |---|---|
89
89
  | `get_issue` | Ein Issue anhand seiner ID abrufen |
90
- | `list_issues` | Issues nach Projekt, Status, Autor u.v.m. filtern; optionales `select` für Feldprojektion und `status` für clientseitige Statusfilterung |
90
+ | `list_issues` | Issues nach Projekt, Status, Autor u.v.m. filtern; optionales `select` für Feldprojektion und `status` für clientseitige Statusfilterung — kanonische englische Statusnamen (z.B. `"new"`, `"resolved"`) werden per ID abgeglichen und funktionieren damit sprachunabhängig auf lokalisierten Installationen |
91
91
  | `create_issue` | Neues Issue anlegen; `severity` und `priority` müssen kanonische englische Namen sein (z.B. `minor`, `major`, `normal`, `high`) — `get_issue_enums` aufrufen, um alle gültigen Werte und deren lokalisierte Bezeichnungen zu sehen; optionaler `handler`-Parameter akzeptiert einen Benutzernamen als Alternative zu `handler_id` (wird gegen die Projektmitglieder aufgelöst) |
92
- | `update_issue` | Bestehendes Issue bearbeiten |
92
+ | `update_issue` | Bestehendes Issue bearbeiten; Enum-Felder (`status`, `priority`, `severity`, `resolution`, `reproducibility`) akzeptieren kanonische englische Namen, lokalisierte Namen oder numerische IDs — der Server löst Namen automatisch zu IDs auf |
93
93
  | `delete_issue` | Issue löschen |
94
94
 
95
95
  ### Notizen
@@ -153,7 +153,7 @@ Aktivierung mit `MANTIS_SEARCH_ENABLED=true`.
153
153
 
154
154
  | Tool | Beschreibung |
155
155
  |---|---|
156
- | `search_issues` | Natürlichsprachige Suche über alle indizierten Issues – liefert Top-N-Ergebnisse mit Cosine-Similarity-Score; optionales `select` (kommagetrennte Feldnamen) reichert jedes Ergebnis mit den angeforderten Issue-Feldern an |
156
+ | `search_issues` | Natürlichsprachige Suche über alle indizierten Issues – liefert Top-N-Ergebnisse mit Cosine-Similarity-Score; optionales `select` (kommagetrennte Feldnamen) reichert jedes Ergebnis mit den angeforderten Issue-Feldern an; optionales `highlight` (boolean, Standard `false`) fügt je Ergebnis ein `highlights`-Feld mit keyword-basierten Ausschnitten aus `summary` und `description` hinzu (Treffer werden in `**fett**` dargestellt) |
157
157
  | `rebuild_search_index` | Suchindex aufbauen oder aktualisieren; `full: true` löscht und baut ihn vollständig neu |
158
158
  | `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 |
159
159
 
package/README.md CHANGED
@@ -87,9 +87,9 @@ npm run build
87
87
  | Tool | Description |
88
88
  |---|---|
89
89
  | `get_issue` | Retrieve an issue by its numeric ID |
90
- | `list_issues` | Filter issues by project, status, author, and more; optional `select` for field projection and `status` for client-side status filtering |
90
+ | `list_issues` | Filter issues by project, status, author, and more; optional `select` for field projection and `status` for client-side status filtering — canonical English status names (e.g. `"new"`, `"resolved"`) are matched by ID, making the filter language-independent on localized installations |
91
91
  | `create_issue` | Create a new issue; `severity` and `priority` must be canonical English names (e.g. `minor`, `major`, `normal`, `high`) — call `get_issue_enums` to see all valid values and their localized labels; optional `handler` parameter accepts a username as alternative to `handler_id` (resolved against project members) |
92
- | `update_issue` | Update an existing issue |
92
+ | `update_issue` | Update an existing issue; enum fields (`status`, `priority`, `severity`, `resolution`, `reproducibility`) accept canonical English names, localized names, or numeric IDs — the server resolves names to IDs automatically |
93
93
  | `delete_issue` | Delete an issue |
94
94
 
95
95
  ### Notes
@@ -153,7 +153,7 @@ Activate with `MANTIS_SEARCH_ENABLED=true`.
153
153
 
154
154
  | Tool | Description |
155
155
  |---|---|
156
- | `search_issues` | Natural language search over all indexed issues — returns top-N results with cosine similarity score; optional `select` (comma-separated field names) enriches each result with the requested issue fields |
156
+ | `search_issues` | Natural language search over all indexed issues — returns top-N results with cosine similarity score; optional `select` (comma-separated field names) enriches each result with the requested issue fields; optional `highlight` (boolean, default `false`) adds a `highlights` field per result with keyword-matched excerpts from `summary` and `description` (matched terms shown in `**bold**`) |
157
157
  | `rebuild_search_index` | Build or update the search index; `full: true` clears and rebuilds from scratch |
158
158
  | `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 |
159
159
 
package/dist/client.js CHANGED
@@ -96,10 +96,12 @@ export class MantisClient {
96
96
  // ---------------------------------------------------------------------------
97
97
  // Public API methods
98
98
  // ---------------------------------------------------------------------------
99
+ static TIMEOUT_MS = 30_000;
99
100
  async get(path, params) {
100
101
  const response = await fetch(await this.buildUrl(path, params), {
101
102
  method: 'GET',
102
103
  headers: await this.headers(),
104
+ signal: AbortSignal.timeout(MantisClient.TIMEOUT_MS),
103
105
  });
104
106
  return this.handleResponse(response);
105
107
  }
@@ -108,6 +110,7 @@ export class MantisClient {
108
110
  method: 'POST',
109
111
  headers: await this.headers(),
110
112
  body: JSON.stringify(body),
113
+ signal: AbortSignal.timeout(MantisClient.TIMEOUT_MS),
111
114
  });
112
115
  return this.handleResponse(response);
113
116
  }
@@ -116,6 +119,7 @@ export class MantisClient {
116
119
  method: 'PATCH',
117
120
  headers: await this.headers(),
118
121
  body: JSON.stringify(body),
122
+ signal: AbortSignal.timeout(MantisClient.TIMEOUT_MS),
119
123
  });
120
124
  return this.handleResponse(response);
121
125
  }
@@ -123,6 +127,7 @@ export class MantisClient {
123
127
  const response = await fetch(await this.buildUrl(path), {
124
128
  method: 'DELETE',
125
129
  headers: await this.headers(),
130
+ signal: AbortSignal.timeout(MantisClient.TIMEOUT_MS),
126
131
  });
127
132
  return this.handleResponse(response);
128
133
  }
@@ -137,6 +142,7 @@ export class MantisClient {
137
142
  'Accept': 'application/json',
138
143
  },
139
144
  body: formData,
145
+ signal: AbortSignal.timeout(MantisClient.TIMEOUT_MS),
140
146
  });
141
147
  return this.handleResponse(response);
142
148
  }
@@ -144,6 +150,7 @@ export class MantisClient {
144
150
  const response = await fetch(await this.buildUrl('users/me'), {
145
151
  method: 'GET',
146
152
  headers: await this.headers(),
153
+ signal: AbortSignal.timeout(MantisClient.TIMEOUT_MS),
147
154
  });
148
155
  if (!response.ok) {
149
156
  throw new MantisApiError(response.status, response.statusText);
package/dist/config.js CHANGED
@@ -54,6 +54,7 @@ function readNonCredentialConfig() {
54
54
  modelName: searchModelName,
55
55
  numThreads: searchNumThreads,
56
56
  },
57
+ testEnvironment: process.env.MCP_TEST_ENVIRONMENT === 'true',
57
58
  };
58
59
  }
59
60
  /**
@@ -0,0 +1,55 @@
1
+ import { z } from 'zod';
2
+ // ---------------------------------------------------------------------------
3
+ // Shared Zod schema fragment — reuse in any tool's inputSchema
4
+ // ---------------------------------------------------------------------------
5
+ export const dateFilterSchema = {
6
+ updated_after: z.string().optional().describe('ISO-8601 timestamp — only return issues updated after this date (exclusive). Example: "2026-03-25T00:00:00Z"'),
7
+ updated_before: z.string().optional().describe('ISO-8601 timestamp — only return issues updated before this date (exclusive). Example: "2026-03-28T00:00:00Z"'),
8
+ created_after: z.string().optional().describe('ISO-8601 timestamp — only return issues created after this date (exclusive). Example: "2026-03-01T00:00:00Z"'),
9
+ created_before: z.string().optional().describe('ISO-8601 timestamp — only return issues created before this date (exclusive). Example: "2026-03-15T00:00:00Z"'),
10
+ };
11
+ // ---------------------------------------------------------------------------
12
+ // matchesDateFilter
13
+ // ---------------------------------------------------------------------------
14
+ /**
15
+ * Returns true if the item's dates satisfy all active date constraints.
16
+ * All comparisons are exclusive (strictly greater / strictly less than).
17
+ * If a filter is set but the item's corresponding date field is absent, returns false.
18
+ */
19
+ export function matchesDateFilter(item, filter) {
20
+ const { updated_after, updated_before, created_after, created_before } = filter;
21
+ if (updated_after !== undefined) {
22
+ if (!item.updated_at)
23
+ return false;
24
+ if (new Date(item.updated_at) <= new Date(updated_after))
25
+ return false;
26
+ }
27
+ if (updated_before !== undefined) {
28
+ if (!item.updated_at)
29
+ return false;
30
+ if (new Date(item.updated_at) >= new Date(updated_before))
31
+ return false;
32
+ }
33
+ if (created_after !== undefined) {
34
+ if (!item.created_at)
35
+ return false;
36
+ if (new Date(item.created_at) <= new Date(created_after))
37
+ return false;
38
+ }
39
+ if (created_before !== undefined) {
40
+ if (!item.created_at)
41
+ return false;
42
+ if (new Date(item.created_at) >= new Date(created_before))
43
+ return false;
44
+ }
45
+ return true;
46
+ }
47
+ /**
48
+ * Returns true if any date filter parameter is set.
49
+ */
50
+ export function hasDateFilter(filter) {
51
+ return (filter.updated_after !== undefined ||
52
+ filter.updated_before !== undefined ||
53
+ filter.created_after !== undefined ||
54
+ filter.created_before !== undefined);
55
+ }
package/dist/index.js CHANGED
@@ -63,7 +63,7 @@ async function createMcpServer() {
63
63
  registerTagTools(server, client);
64
64
  registerVersionTools(server, client, versionHint, version);
65
65
  registerPrompts(server);
66
- registerResources(server, client, cache);
66
+ registerResources(server, client, cache, startupConfig.testEnvironment);
67
67
  // Optional: Semantic search module
68
68
  if (startupConfig.search.enabled) {
69
69
  const { initializeSearchModule } = await import('./search/index.js');
@@ -7,7 +7,7 @@ function jsonResource(uri, data) {
7
7
  contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(data) }],
8
8
  };
9
9
  }
10
- export function registerResources(server, client, cache) {
10
+ export function registerResources(server, client, cache, testEnvironment = false) {
11
11
  async function loadProjects() {
12
12
  const cached = await cache.loadIfValid();
13
13
  return cached?.projects
@@ -25,12 +25,19 @@ export function registerResources(server, client, cache) {
25
25
  mimeType: 'application/json',
26
26
  }, async (uri) => jsonResource(uri, await loadProjects()));
27
27
  server.registerResource('project-detail', new ResourceTemplate('mantis://projects/{id}', {
28
- list: async () => ({
29
- resources: (await loadProjects()).map((p) => ({
30
- uri: `mantis://projects/${p.id}`,
31
- name: p.name,
32
- })),
33
- }),
28
+ list: async () => {
29
+ // In test environments (MCP_TEST_ENVIRONMENT=true, e.g. Glama inspection
30
+ // with placeholder credentials) skip the live API call and return an
31
+ // empty list so resources/list responds immediately without timing out.
32
+ if (testEnvironment)
33
+ return { resources: [] };
34
+ return {
35
+ resources: (await loadProjects()).map((p) => ({
36
+ uri: `mantis://projects/${p.id}`,
37
+ name: p.name,
38
+ })),
39
+ };
40
+ },
34
41
  }), {
35
42
  title: 'Project Detail',
36
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 the sync_metadata tool.',
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Keyword highlighting utilities for search_issues results.
3
+ *
4
+ * Note: highlights are keyword-based (lexical), not semantic.
5
+ * A result may have no highlighted terms even if it is semantically relevant.
6
+ */
7
+ function escapeRegex(term) {
8
+ return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
9
+ }
10
+ function combinedPattern(terms, flags) {
11
+ return new RegExp(`\\b(${terms.map(escapeRegex).join('|')})\\b`, flags);
12
+ }
13
+ /**
14
+ * Extracts meaningful search terms from a query string.
15
+ * Sorted longest-first to prevent shorter terms from matching inside
16
+ * already-bolded longer ones during sequential replacement.
17
+ */
18
+ export function extractTerms(query) {
19
+ const seen = new Set();
20
+ const terms = [];
21
+ for (const raw of query.trim().split(/\s+/)) {
22
+ if (raw.length < 3)
23
+ continue;
24
+ const key = raw.toLowerCase();
25
+ if (!seen.has(key)) {
26
+ seen.add(key);
27
+ terms.push(raw);
28
+ }
29
+ }
30
+ terms.sort((a, b) => b.length - a.length);
31
+ return terms;
32
+ }
33
+ export function highlightText(text, terms) {
34
+ if (!terms.length)
35
+ return text;
36
+ return text.replace(combinedPattern(terms, 'gi'), '**$1**');
37
+ }
38
+ export function hasTermMatch(text, terms) {
39
+ if (!terms.length)
40
+ return false;
41
+ return combinedPattern(terms, 'i').test(text);
42
+ }
43
+ /**
44
+ * Returns a highlighted snippet centered around the first term match.
45
+ * Falls back to first `contextChars` chars when no term matches.
46
+ */
47
+ export function extractSnippet(text, terms, contextChars = 300) {
48
+ if (text.length <= contextChars) {
49
+ return highlightText(text, terms);
50
+ }
51
+ const match = terms.length > 0 ? text.match(combinedPattern(terms, 'i')) : null;
52
+ const matchIndex = match?.index ?? -1;
53
+ if (matchIndex === -1) {
54
+ return text.slice(0, contextChars) + '…';
55
+ }
56
+ const half = Math.floor(contextChars / 2);
57
+ const start = Math.max(0, matchIndex - half);
58
+ const end = Math.min(text.length, start + contextChars);
59
+ const snippet = text.slice(start, end);
60
+ const prefix = start > 0 ? '…' : '';
61
+ const suffix = end < text.length ? '…' : '';
62
+ return prefix + highlightText(snippet, terms) + suffix;
63
+ }
@@ -64,6 +64,10 @@ export class VectraStore {
64
64
  results.sort((a, b) => b.score - a.score);
65
65
  return results.slice(0, topN);
66
66
  }
67
+ async getItem(id) {
68
+ await this.ensureLoaded();
69
+ return this.items.get(id) ?? null;
70
+ }
67
71
  async delete(id) {
68
72
  await this.ensureLoaded();
69
73
  this.items.delete(id);
@@ -1,6 +1,20 @@
1
1
  import { z } from 'zod';
2
2
  import { SearchSyncService } from './sync.js';
3
3
  import { getVersionHint } from '../version-hint.js';
4
+ import { dateFilterSchema, matchesDateFilter, hasDateFilter } from '../date-filter.js';
5
+ import { extractTerms, highlightText, extractSnippet, hasTermMatch } from './highlight.js';
6
+ function buildHighlights(summary, description, terms) {
7
+ const h = {};
8
+ if (summary) {
9
+ const highlighted = highlightText(summary, terms);
10
+ if (highlighted !== summary)
11
+ h['summary'] = highlighted;
12
+ }
13
+ if (description && hasTermMatch(description, terms)) {
14
+ h['description'] = extractSnippet(description, terms);
15
+ }
16
+ return Object.keys(h).length > 0 ? h : null;
17
+ }
4
18
  function errorText(msg) {
5
19
  const vh = getVersionHint();
6
20
  vh?.triggerLatestVersionFetch();
@@ -30,13 +44,20 @@ export function registerSearchTools(server, client, store, embedder) {
30
44
  select: z.string().optional().describe('Comma-separated list of fields to include for each result (e.g. "id,summary,status,handler,priority"). ' +
31
45
  'When provided, each matching issue is fetched from MantisBT and enriched with the requested fields. ' +
32
46
  'The relevance score is always included. Without this parameter only id and score are returned.'),
47
+ highlight: z
48
+ .boolean()
49
+ .default(false)
50
+ .describe('If true, adds a "highlights" field per result with query terms bolded (**term**) ' +
51
+ 'in the issue summary and a short description snippet. ' +
52
+ 'Note: highlights are keyword-based, not semantic — some results may have no highlighted terms.'),
53
+ ...dateFilterSchema,
33
54
  }),
34
55
  annotations: {
35
56
  readOnlyHint: true,
36
57
  destructiveHint: false,
37
58
  idempotentHint: true,
38
59
  },
39
- }, async ({ query, top_n, select }) => {
60
+ }, async ({ query, top_n, select, highlight, updated_after, updated_before, created_after, created_before }) => {
40
61
  try {
41
62
  const count = await store.count();
42
63
  if (count === 0) {
@@ -50,11 +71,32 @@ export function registerSearchTools(server, client, store, embedder) {
50
71
  isError: true,
51
72
  };
52
73
  }
74
+ const dateFilter = { updated_after, updated_before, created_after, created_before };
75
+ const filterActive = hasDateFilter(dateFilter);
76
+ const terms = highlight ? extractTerms(query) : [];
53
77
  const queryVector = await embedder.embed(query);
54
78
  const results = await store.search(queryVector, top_n);
55
79
  if (!select) {
80
+ // For filtering or highlighting we need store metadata per result
81
+ if (!filterActive && !terms.length) {
82
+ return {
83
+ content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
84
+ };
85
+ }
86
+ const filtered = await Promise.all(results.map(async ({ id, score }) => {
87
+ const item = await store.getItem(id);
88
+ if (filterActive && !matchesDateFilter(item?.metadata ?? {}, dateFilter))
89
+ return null;
90
+ const result = { id, score };
91
+ if (terms.length > 0 && item) {
92
+ const h = buildHighlights(item.metadata.summary, item.metadata.description, terms);
93
+ if (h)
94
+ result['highlights'] = h;
95
+ }
96
+ return result;
97
+ }));
56
98
  return {
57
- content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
99
+ content: [{ type: 'text', text: JSON.stringify(filtered.filter(Boolean), null, 2) }],
58
100
  };
59
101
  }
60
102
  const fields = select.split(',').map(f => f.trim()).filter(Boolean);
@@ -62,20 +104,39 @@ export function registerSearchTools(server, client, store, embedder) {
62
104
  try {
63
105
  const issueResult = await client.get(`issues/${id}`);
64
106
  const issue = issueResult.issues?.[0] ?? {};
107
+ if (filterActive && !matchesDateFilter(issue, dateFilter)) {
108
+ return null;
109
+ }
65
110
  const projected = { id, score };
66
111
  for (const field of fields) {
67
112
  if (field !== 'id' && field in issue) {
68
113
  projected[field] = issue[field];
69
114
  }
70
115
  }
116
+ if (terms.length > 0) {
117
+ const summary = typeof issue['summary'] === 'string' ? issue['summary'] : undefined;
118
+ const description = typeof issue['description'] === 'string' ? issue['description'] : undefined;
119
+ const h = buildHighlights(summary, description, terms);
120
+ if (h)
121
+ projected['highlights'] = h;
122
+ }
71
123
  return projected;
72
124
  }
73
125
  catch {
74
- return { id, score };
126
+ const result = { id, score };
127
+ if (terms.length > 0) {
128
+ const item = await store.getItem(id);
129
+ if (item) {
130
+ const h = buildHighlights(item.metadata.summary, item.metadata.description, terms);
131
+ if (h)
132
+ result['highlights'] = h;
133
+ }
134
+ }
135
+ return result;
75
136
  }
76
137
  }));
77
138
  return {
78
- content: [{ type: 'text', text: JSON.stringify(enriched, null, 2) }],
139
+ content: [{ type: 'text', text: JSON.stringify(enriched.filter(Boolean), null, 2) }],
79
140
  };
80
141
  }
81
142
  catch (error) {
@@ -68,6 +68,20 @@ export async function fetchIssueEnums(client) {
68
68
  }
69
69
  return enums;
70
70
  }
71
+ let _enumDataCache = null;
72
+ const ENUM_DATA_CACHE_TTL_MS = 5 * 60 * 1000;
73
+ export async function fetchIssueEnumsWithCache(client) {
74
+ if (_enumDataCache && (Date.now() - _enumDataCache.fetchedAt) < ENUM_DATA_CACHE_TTL_MS) {
75
+ return _enumDataCache.data;
76
+ }
77
+ const data = await fetchIssueEnums(client);
78
+ _enumDataCache = { data, fetchedAt: Date.now() };
79
+ return data;
80
+ }
81
+ /** Resets the in-memory enum cache. Intended for use in tests. */
82
+ export function clearIssueEnumCache() {
83
+ _enumDataCache = null;
84
+ }
71
85
  export function registerConfigTools(server, client, cache) {
72
86
  // ---------------------------------------------------------------------------
73
87
  // get_config
@@ -139,18 +153,19 @@ Example response (localized installation, e.g. German):
139
153
  }
140
154
 
141
155
  Fields:
142
- - "id" — numeric ID accepted by the API
143
- - "name" the API value to pass to create_issue or update_issue; normally English, but may be
144
- localized if the installation has customized enum values in the database
145
- - "label" — localized display label shown in the UI (only present when it differs from "name")
156
+ - "id" — numeric ID accepted by the API
157
+ - "name" localized or canonical name from the MantisBT database
158
+ - "label" — UI display label (only present when it differs from "name")
159
+ - "canonical_name" — English canonical name (only present on localized installs)
160
+
161
+ For create_issue (severity, priority, reproducibility): pass the canonical English name, the
162
+ localized "name", or the "label" — all are accepted. The server resolves them to the correct ID.
146
163
 
147
- Always pass either the "id" or the "name" value to create_issue or update_issue — never the "label".
148
- Use the "label" to map user input in the UI language back to the correct "name"/"id" for the API.
164
+ For update_issue: pass either "id" or "name" in the field reference object.
149
165
 
150
166
  Note: on some installations enum values are customized at the database level. In that case "name"
151
167
  itself may be localized (e.g. "kleinerer Fehler" instead of "minor") and no "label" will be present
152
- because there is no separate English original. The "name" value returned is always the correct one
153
- to use for API calls — regardless of language.`,
168
+ because there is no separate English original.`,
154
169
  inputSchema: z.object({}),
155
170
  annotations: {
156
171
  readOnlyHint: true,