@dpesch/mantisbt-mcp-server 1.8.3 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,7 +7,32 @@ This project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
- ## [Unreleased]
10
+ ## [1.9.1] – 2026-03-30
11
+
12
+ ### Fixed
13
+ - Boolean parameters (`dry_run`, `highlight`, `check_latest`, `obsolete`, `inherit`) now accept the strings `"true"` and `"false"` in addition to native booleans. MCP clients that serialize all parameters as JSON strings no longer receive error -32602. Note: `z.coerce.boolean()` was intentionally not used — it would silently convert the string `"false"` to `true` via JavaScript's `Boolean()`.
14
+ - `update_issue`: the `fields` parameter now accepts a JSON-encoded string in addition to a plain object. Invalid JSON is caught and surfaced as a Zod validation error instead of an uncaught `SyntaxError`.
15
+
16
+ ---
17
+
18
+ ## [1.9.0] – 2026-03-28
19
+
20
+ ### Added
21
+ - 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.
22
+ - `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.
23
+ - `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.
24
+ - `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.
25
+ - `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.
26
+ - `VectorStore` interface extended with `getItem(id)` to support metadata lookups without re-fetching from the API.
27
+ - New internal module `src/date-filter.ts` with shared `matchesDateFilter`, `hasDateFilter`, and `dateFilterSchema` — reused by both tools.
28
+ - New internal module `src/search/highlight.ts` with `extractTerms`, `highlightText`, `hasTermMatch`, and `extractSnippet` — used by `search_issues` highlighting.
29
+
30
+ ### Changed
31
+ - `get_issue` tool description now explicitly states that notes are always included in the response — no separate `list_notes` call needed.
32
+ - `list_notes` tool description now clarifies it is only needed when fetching notes without the full issue object.
33
+ - `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.
34
+ - `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.
35
+ - `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.
11
36
 
12
37
  ---
13
38
 
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
 
@@ -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
+ }
@@ -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();
@@ -10,6 +24,7 @@ function errorText(msg) {
10
24
  // ---------------------------------------------------------------------------
11
25
  // registerSearchTools
12
26
  // ---------------------------------------------------------------------------
27
+ const coerceBool = (val) => val === 'true' ? true : val === 'false' ? false : val;
13
28
  export function registerSearchTools(server, client, store, embedder) {
14
29
  // ---------------------------------------------------------------------------
15
30
  // search_issues
@@ -30,13 +45,20 @@ export function registerSearchTools(server, client, store, embedder) {
30
45
  select: z.string().optional().describe('Comma-separated list of fields to include for each result (e.g. "id,summary,status,handler,priority"). ' +
31
46
  'When provided, each matching issue is fetched from MantisBT and enriched with the requested fields. ' +
32
47
  'The relevance score is always included. Without this parameter only id and score are returned.'),
48
+ highlight: z
49
+ .preprocess(coerceBool, z.boolean())
50
+ .default(false)
51
+ .describe('If true, adds a "highlights" field per result with query terms bolded (**term**) ' +
52
+ 'in the issue summary and a short description snippet. ' +
53
+ 'Note: highlights are keyword-based, not semantic — some results may have no highlighted terms.'),
54
+ ...dateFilterSchema,
33
55
  }),
34
56
  annotations: {
35
57
  readOnlyHint: true,
36
58
  destructiveHint: false,
37
59
  idempotentHint: true,
38
60
  },
39
- }, async ({ query, top_n, select }) => {
61
+ }, async ({ query, top_n, select, highlight, updated_after, updated_before, created_after, created_before }) => {
40
62
  try {
41
63
  const count = await store.count();
42
64
  if (count === 0) {
@@ -50,11 +72,32 @@ export function registerSearchTools(server, client, store, embedder) {
50
72
  isError: true,
51
73
  };
52
74
  }
75
+ const dateFilter = { updated_after, updated_before, created_after, created_before };
76
+ const filterActive = hasDateFilter(dateFilter);
77
+ const terms = highlight ? extractTerms(query) : [];
53
78
  const queryVector = await embedder.embed(query);
54
79
  const results = await store.search(queryVector, top_n);
55
80
  if (!select) {
81
+ // For filtering or highlighting we need store metadata per result
82
+ if (!filterActive && !terms.length) {
83
+ return {
84
+ content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
85
+ };
86
+ }
87
+ const filtered = await Promise.all(results.map(async ({ id, score }) => {
88
+ const item = await store.getItem(id);
89
+ if (filterActive && !matchesDateFilter(item?.metadata ?? {}, dateFilter))
90
+ return null;
91
+ const result = { id, score };
92
+ if (terms.length > 0 && item) {
93
+ const h = buildHighlights(item.metadata.summary, item.metadata.description, terms);
94
+ if (h)
95
+ result['highlights'] = h;
96
+ }
97
+ return result;
98
+ }));
56
99
  return {
57
- content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
100
+ content: [{ type: 'text', text: JSON.stringify(filtered.filter(Boolean), null, 2) }],
58
101
  };
59
102
  }
60
103
  const fields = select.split(',').map(f => f.trim()).filter(Boolean);
@@ -62,20 +105,39 @@ export function registerSearchTools(server, client, store, embedder) {
62
105
  try {
63
106
  const issueResult = await client.get(`issues/${id}`);
64
107
  const issue = issueResult.issues?.[0] ?? {};
108
+ if (filterActive && !matchesDateFilter(issue, dateFilter)) {
109
+ return null;
110
+ }
65
111
  const projected = { id, score };
66
112
  for (const field of fields) {
67
113
  if (field !== 'id' && field in issue) {
68
114
  projected[field] = issue[field];
69
115
  }
70
116
  }
117
+ if (terms.length > 0) {
118
+ const summary = typeof issue['summary'] === 'string' ? issue['summary'] : undefined;
119
+ const description = typeof issue['description'] === 'string' ? issue['description'] : undefined;
120
+ const h = buildHighlights(summary, description, terms);
121
+ if (h)
122
+ projected['highlights'] = h;
123
+ }
71
124
  return projected;
72
125
  }
73
126
  catch {
74
- return { id, score };
127
+ const result = { id, score };
128
+ if (terms.length > 0) {
129
+ const item = await store.getItem(id);
130
+ if (item) {
131
+ const h = buildHighlights(item.metadata.summary, item.metadata.description, terms);
132
+ if (h)
133
+ result['highlights'] = h;
134
+ }
135
+ }
136
+ return result;
75
137
  }
76
138
  }));
77
139
  return {
78
- content: [{ type: 'text', text: JSON.stringify(enriched, null, 2) }],
140
+ content: [{ type: 'text', text: JSON.stringify(enriched.filter(Boolean), null, 2) }],
79
141
  };
80
142
  }
81
143
  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,