@dpesch/mantisbt-mcp-server 1.8.3 → 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 +21 -0
- package/README.de.md +3 -3
- package/README.md +3 -3
- package/dist/date-filter.js +55 -0
- package/dist/search/highlight.js +63 -0
- package/dist/search/store.js +4 -0
- package/dist/search/tools.js +65 -4
- package/dist/tools/config.js +23 -8
- package/dist/tools/issues.js +123 -18
- package/dist/tools/notes.js +1 -1
- package/docs/cookbook.de.md +64 -7
- package/docs/cookbook.md +64 -7
- package/docs/examples.de.md +12 -0
- package/docs/examples.md +12 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/tests/fixtures/get_issue.json +22 -0
- package/tests/helpers/search-mocks.ts +29 -6
- package/tests/search/highlight.test.ts +129 -0
- package/tests/search/tools.test.ts +258 -0
- package/tests/tools/issues.test.ts +446 -4
- package/tests/utils/date-filter.test.ts +169 -0
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,27 @@ 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
|
+
|
|
14
35
|
## [1.8.3] – 2026-03-28
|
|
15
36
|
|
|
16
37
|
### Added
|
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
|
+
}
|
package/dist/search/store.js
CHANGED
|
@@ -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);
|
package/dist/search/tools.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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) {
|
package/dist/tools/config.js
CHANGED
|
@@ -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"
|
|
143
|
-
- "name"
|
|
144
|
-
|
|
145
|
-
- "
|
|
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
|
-
|
|
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
|
|
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,
|
package/dist/tools/issues.js
CHANGED
|
@@ -1,20 +1,48 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { getVersionHint } from '../version-hint.js';
|
|
3
3
|
import { MANTIS_CANONICAL_ENUM_NAMES, MANTIS_RESOLVED_STATUS_ID, resolveEnumId } from '../constants.js';
|
|
4
|
+
import { dateFilterSchema, matchesDateFilter, hasDateFilter } from '../date-filter.js';
|
|
5
|
+
import { fetchIssueEnumsWithCache } from './config.js';
|
|
4
6
|
function errorText(msg) {
|
|
5
7
|
const vh = getVersionHint();
|
|
6
8
|
vh?.triggerLatestVersionFetch();
|
|
7
9
|
const hint = vh?.getUpdateHint();
|
|
8
10
|
return hint ? `Error: ${msg}\n\n${hint}` : `Error: ${msg}`;
|
|
9
11
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
const GET_ISSUES_CONCURRENCY = 5;
|
|
13
|
+
// Worker-pool: runs `fn` over all `items` with at most `concurrency` in-flight at once.
|
|
14
|
+
// nextIndex is only incremented inside microtasks, so the ++ is safe without a lock.
|
|
15
|
+
async function runWithConcurrency(items, concurrency, fn) {
|
|
16
|
+
const results = new Array(items.length);
|
|
17
|
+
let nextIndex = 0;
|
|
18
|
+
async function worker() {
|
|
19
|
+
while (nextIndex < items.length) {
|
|
20
|
+
const i = nextIndex++;
|
|
21
|
+
results[i] = await fn(items[i]);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
|
|
25
|
+
return results;
|
|
26
|
+
}
|
|
27
|
+
// Resolves an enum name (canonical or localized) to { id } or returns an error string.
|
|
28
|
+
async function resolveEnum(group, value, client) {
|
|
12
29
|
const id = resolveEnumId(group, value);
|
|
13
|
-
if (id
|
|
14
|
-
|
|
15
|
-
|
|
30
|
+
if (id !== undefined)
|
|
31
|
+
return { id };
|
|
32
|
+
try {
|
|
33
|
+
const enums = await fetchIssueEnumsWithCache(client);
|
|
34
|
+
const entries = enums[group] ?? [];
|
|
35
|
+
const lower = value.toLowerCase();
|
|
36
|
+
const entry = entries.find(e => e.name.toLowerCase() === lower ||
|
|
37
|
+
(e.label !== undefined && e.label.toLowerCase() === lower));
|
|
38
|
+
if (entry !== undefined)
|
|
39
|
+
return { id: entry.id };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// localized lookup unavailable — fall through to static error
|
|
16
43
|
}
|
|
17
|
-
|
|
44
|
+
const valid = Object.values(MANTIS_CANONICAL_ENUM_NAMES[group]).join(', ');
|
|
45
|
+
return `Invalid ${group} "${value}". Valid canonical names: ${valid}. Call get_issue_enums to see localized labels.`;
|
|
18
46
|
}
|
|
19
47
|
export function registerIssueTools(server, client, cache) {
|
|
20
48
|
// ---------------------------------------------------------------------------
|
|
@@ -22,7 +50,7 @@ export function registerIssueTools(server, client, cache) {
|
|
|
22
50
|
// ---------------------------------------------------------------------------
|
|
23
51
|
server.registerTool('get_issue', {
|
|
24
52
|
title: 'Get Issue',
|
|
25
|
-
description: 'Retrieve a single MantisBT issue by its numeric ID. Returns all issue fields including notes, attachments, and relationships.',
|
|
53
|
+
description: 'Retrieve a single MantisBT issue by its numeric ID. Returns all issue fields including notes, attachments, and relationships. Notes are always included — no separate list_notes call needed.',
|
|
26
54
|
inputSchema: z.object({
|
|
27
55
|
id: z.coerce.number().int().positive().describe('Numeric issue ID'),
|
|
28
56
|
}),
|
|
@@ -45,11 +73,51 @@ export function registerIssueTools(server, client, cache) {
|
|
|
45
73
|
}
|
|
46
74
|
});
|
|
47
75
|
// ---------------------------------------------------------------------------
|
|
76
|
+
// get_issues
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
server.registerTool('get_issues', {
|
|
79
|
+
title: 'Get Multiple Issues',
|
|
80
|
+
description: 'Retrieve multiple MantisBT issues by their numeric IDs in a single MCP call. ' +
|
|
81
|
+
'Requests run in parallel (max 5 concurrent). ' +
|
|
82
|
+
'Missing or inaccessible IDs return null at their array position — ' +
|
|
83
|
+
'the call never fails due to individual missing IDs. ' +
|
|
84
|
+
'Response includes "requested", "found", and "failed" counters for quick validation.',
|
|
85
|
+
inputSchema: z.object({
|
|
86
|
+
ids: z
|
|
87
|
+
.array(z.coerce.number().int().positive())
|
|
88
|
+
.min(1)
|
|
89
|
+
.max(50)
|
|
90
|
+
.describe('Array of numeric issue IDs to fetch (1–50). null is returned per ID on 404/403/error instead of failing the whole call.'),
|
|
91
|
+
}),
|
|
92
|
+
annotations: {
|
|
93
|
+
readOnlyHint: true,
|
|
94
|
+
destructiveHint: false,
|
|
95
|
+
idempotentHint: true,
|
|
96
|
+
},
|
|
97
|
+
}, async ({ ids }) => {
|
|
98
|
+
const results = await runWithConcurrency(ids, GET_ISSUES_CONCURRENCY, async (id) => {
|
|
99
|
+
try {
|
|
100
|
+
const result = await client.get(`issues/${id}`);
|
|
101
|
+
return result.issues?.[0] ?? result;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
const found = results.filter((r) => r !== null).length;
|
|
108
|
+
return {
|
|
109
|
+
content: [{
|
|
110
|
+
type: 'text',
|
|
111
|
+
text: JSON.stringify({ issues: results, requested: ids.length, found, failed: ids.length - found }, null, 2),
|
|
112
|
+
}],
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
48
116
|
// list_issues
|
|
49
117
|
// ---------------------------------------------------------------------------
|
|
50
118
|
server.registerTool('list_issues', {
|
|
51
119
|
title: 'List Issues',
|
|
52
|
-
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.\n\nNote: "assigned_to", "reporter_id",
|
|
120
|
+
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.\n\nNote: "assigned_to", "reporter_id", "status", and date filters are applied client-side (the MantisBT REST API does not support these as server-side filters). When any of these filters are active the tool automatically fetches multiple pages internally until enough matching results are found (up to 500 issues scanned). The "page" and "page_size" parameters refer to the resulting filtered list.\n\nTip for date queries: fetching with select="id,updated_at,created_at" plus a date filter is very compact and efficient.',
|
|
53
121
|
inputSchema: z.object({
|
|
54
122
|
project_id: z.coerce.number().int().positive().optional().describe('Filter by project ID'),
|
|
55
123
|
page: z.coerce.number().int().positive().default(1).describe('Page number (default: 1)'),
|
|
@@ -61,13 +129,14 @@ export function registerIssueTools(server, client, cache) {
|
|
|
61
129
|
direction: z.enum(['ASC', 'DESC']).optional().describe('Sort direction'),
|
|
62
130
|
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"'),
|
|
63
131
|
status: z.string().optional().describe('Filter issues by status name (e.g. "new", "feedback", "acknowledged", "confirmed", "assigned", "resolved", "closed") or use "open" as shorthand for all statuses with id < 80 (i.e. not yet resolved or closed). Applied client-side after fetching — when combined with pagination, a page may contain fewer results than page_size.'),
|
|
132
|
+
...dateFilterSchema,
|
|
64
133
|
}),
|
|
65
134
|
annotations: {
|
|
66
135
|
readOnlyHint: true,
|
|
67
136
|
destructiveHint: false,
|
|
68
137
|
idempotentHint: true,
|
|
69
138
|
},
|
|
70
|
-
}, async ({ project_id, page, page_size, assigned_to, reporter_id, filter_id, sort, direction, select, status }) => {
|
|
139
|
+
}, async ({ project_id, page, page_size, assigned_to, reporter_id, filter_id, sort, direction, select, status, updated_after, updated_before, created_after, created_before }) => {
|
|
71
140
|
try {
|
|
72
141
|
const baseParams = {
|
|
73
142
|
project_id,
|
|
@@ -78,7 +147,8 @@ export function registerIssueTools(server, client, cache) {
|
|
|
78
147
|
direction,
|
|
79
148
|
select,
|
|
80
149
|
};
|
|
81
|
-
const
|
|
150
|
+
const dateFilter = { updated_after, updated_before, created_after, created_before };
|
|
151
|
+
const needsClientFilter = status !== undefined || assigned_to !== undefined || reporter_id !== undefined || hasDateFilter(dateFilter);
|
|
82
152
|
if (!needsClientFilter) {
|
|
83
153
|
// No client-side filtering — single API call, pass pagination as-is
|
|
84
154
|
const result = await client.get('issues', { ...baseParams, page, page_size });
|
|
@@ -95,6 +165,9 @@ export function registerIssueTools(server, client, cache) {
|
|
|
95
165
|
let serverPage = 1;
|
|
96
166
|
let hasMore = true;
|
|
97
167
|
const statusLower = status?.toLowerCase();
|
|
168
|
+
const statusId = status ? resolveEnumId('status', status) : undefined;
|
|
169
|
+
// Pre-parse date thresholds once — avoids repeated new Date() inside the scan loop
|
|
170
|
+
const updatedAfterMs = updated_after ? new Date(updated_after).getTime() : undefined;
|
|
98
171
|
while (matching.length < neededTotal && serverPage <= MAX_API_PAGES && hasMore) {
|
|
99
172
|
const batch = await client.get('issues', {
|
|
100
173
|
...baseParams,
|
|
@@ -103,6 +176,7 @@ export function registerIssueTools(server, client, cache) {
|
|
|
103
176
|
});
|
|
104
177
|
const issues = batch.issues ?? [];
|
|
105
178
|
hasMore = issues.length === API_PAGE_SIZE;
|
|
179
|
+
let stopAfterBatch = false;
|
|
106
180
|
for (const issue of issues) {
|
|
107
181
|
if (statusLower) {
|
|
108
182
|
if (!issue.status)
|
|
@@ -111,6 +185,10 @@ export function registerIssueTools(server, client, cache) {
|
|
|
111
185
|
if ((issue.status.id ?? 0) >= MANTIS_RESOLVED_STATUS_ID)
|
|
112
186
|
continue;
|
|
113
187
|
}
|
|
188
|
+
else if (statusId !== undefined) {
|
|
189
|
+
if (issue.status.id !== statusId)
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
114
192
|
else if (issue.status.name?.toLowerCase() !== statusLower) {
|
|
115
193
|
continue;
|
|
116
194
|
}
|
|
@@ -119,8 +197,20 @@ export function registerIssueTools(server, client, cache) {
|
|
|
119
197
|
continue;
|
|
120
198
|
if (reporter_id !== undefined && issue.reporter?.id !== reporter_id)
|
|
121
199
|
continue;
|
|
200
|
+
if (!matchesDateFilter(issue, dateFilter)) {
|
|
201
|
+
// MantisBT returns results newest-first. Once updated_at drops below
|
|
202
|
+
// updated_after, all subsequent pages are guaranteed to be older too.
|
|
203
|
+
// Finish the current batch first (items within it may still be newer),
|
|
204
|
+
// then stop fetching further pages.
|
|
205
|
+
if (updatedAfterMs && issue.updated_at && new Date(issue.updated_at).getTime() <= updatedAfterMs) {
|
|
206
|
+
stopAfterBatch = true;
|
|
207
|
+
}
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
122
210
|
matching.push(issue);
|
|
123
211
|
}
|
|
212
|
+
if (stopAfterBatch)
|
|
213
|
+
break;
|
|
124
214
|
serverPage++;
|
|
125
215
|
}
|
|
126
216
|
const start = (page - 1) * page_size;
|
|
@@ -147,8 +237,8 @@ export function registerIssueTools(server, client, cache) {
|
|
|
147
237
|
description: z.string().min(1).describe('Detailed issue description. Required — do not create issues without a description. Plain text or Markdown.'),
|
|
148
238
|
project_id: z.coerce.number().int().positive().describe('Project ID the issue belongs to'),
|
|
149
239
|
category: z.string().min(1).describe('Category name (use get_project_categories to list available categories)'),
|
|
150
|
-
priority: z.string().default('normal').describe('Priority
|
|
151
|
-
severity: z.string().default('minor').describe('Severity
|
|
240
|
+
priority: z.string().default('normal').describe('Priority: canonical English name (none, low, normal, high, urgent, immediate) or localized label. Default: "normal". Use get_issue_enums to see all available values.'),
|
|
241
|
+
severity: z.string().default('minor').describe('Severity: canonical English name (feature, trivial, text, tweak, minor, major, crash, block) or localized label. Default: "minor". Use get_issue_enums to see all available values.'),
|
|
152
242
|
handler_id: z.coerce.number().int().positive().optional().describe('User ID of the person to assign the issue to'),
|
|
153
243
|
handler: z.string().optional().describe('Username (login name) of the person to assign the issue to. Alternative to handler_id — the server resolves the name to a user ID from the project members. Use get_project_users to see available users.'),
|
|
154
244
|
version: z.string().optional().describe('Affected product version name (use get_project_versions to list available versions)'),
|
|
@@ -156,7 +246,7 @@ export function registerIssueTools(server, client, cache) {
|
|
|
156
246
|
fixed_in_version: z.string().optional().describe('Version name in which the issue was fixed (use get_project_versions to list available versions)'),
|
|
157
247
|
steps_to_reproduce: z.string().optional().describe('Steps to reproduce the issue. Plain text or Markdown.'),
|
|
158
248
|
additional_information: z.string().optional().describe('Additional information about the issue. Plain text or Markdown.'),
|
|
159
|
-
reproducibility: z.string().optional().describe('Reproducibility
|
|
249
|
+
reproducibility: z.string().optional().describe('Reproducibility: canonical English name or localized label (always, sometimes, random, have not tried, unable to reproduce, N/A). Use get_issue_enums to see all available values.'),
|
|
160
250
|
view_state: z.enum(['public', 'private']).optional().describe('Visibility of the issue: "public" (default) or "private"'),
|
|
161
251
|
}),
|
|
162
252
|
annotations: {
|
|
@@ -196,11 +286,11 @@ export function registerIssueTools(server, client, cache) {
|
|
|
196
286
|
project: { id: project_id },
|
|
197
287
|
category: { name: category },
|
|
198
288
|
};
|
|
199
|
-
const priorityResolved = resolveEnum('priority', priority);
|
|
289
|
+
const priorityResolved = await resolveEnum('priority', priority, client);
|
|
200
290
|
if (typeof priorityResolved === 'string')
|
|
201
291
|
return { content: [{ type: 'text', text: errorText(priorityResolved) }], isError: true };
|
|
202
292
|
body.priority = priorityResolved;
|
|
203
|
-
const severityResolved = resolveEnum('severity', severity);
|
|
293
|
+
const severityResolved = await resolveEnum('severity', severity, client);
|
|
204
294
|
if (typeof severityResolved === 'string')
|
|
205
295
|
return { content: [{ type: 'text', text: errorText(severityResolved) }], isError: true };
|
|
206
296
|
body.severity = severityResolved;
|
|
@@ -217,7 +307,7 @@ export function registerIssueTools(server, client, cache) {
|
|
|
217
307
|
if (additional_information !== undefined)
|
|
218
308
|
body.additional_information = additional_information;
|
|
219
309
|
if (reproducibility !== undefined) {
|
|
220
|
-
const reproducibilityResolved = resolveEnum('reproducibility', reproducibility);
|
|
310
|
+
const reproducibilityResolved = await resolveEnum('reproducibility', reproducibility, client);
|
|
221
311
|
if (typeof reproducibilityResolved === 'string')
|
|
222
312
|
return { content: [{ type: 'text', text: errorText(reproducibilityResolved) }], isError: true };
|
|
223
313
|
body.reproducibility = reproducibilityResolved;
|
|
@@ -279,6 +369,7 @@ The "fields" object accepts any combination of:
|
|
|
279
369
|
Important: when resolving an issue, always set BOTH status and resolution to avoid leaving resolution as "open".`,
|
|
280
370
|
inputSchema: z.object({
|
|
281
371
|
id: z.coerce.number().int().positive().describe('Numeric issue ID to update'),
|
|
372
|
+
dry_run: z.boolean().optional().describe('If true, return the patch payload that would be sent without actually updating the issue. Useful for previewing changes before committing them.'),
|
|
282
373
|
fields: z.object({
|
|
283
374
|
summary: z.string().optional(),
|
|
284
375
|
description: z.string().optional(),
|
|
@@ -302,9 +393,23 @@ Important: when resolving an issue, always set BOTH status and resolution to avo
|
|
|
302
393
|
destructiveHint: false,
|
|
303
394
|
idempotentHint: false,
|
|
304
395
|
},
|
|
305
|
-
}, async ({ id, fields }) => {
|
|
396
|
+
}, async ({ id, fields, dry_run }) => {
|
|
397
|
+
if (dry_run) {
|
|
398
|
+
return {
|
|
399
|
+
content: [{ type: 'text', text: JSON.stringify({ dry_run: true, id, would_patch: fields }, null, 2) }],
|
|
400
|
+
};
|
|
401
|
+
}
|
|
306
402
|
try {
|
|
307
|
-
const
|
|
403
|
+
const patch = { ...fields };
|
|
404
|
+
for (const field of ['status', 'priority', 'severity', 'resolution', 'reproducibility']) {
|
|
405
|
+
const val = fields[field];
|
|
406
|
+
if (val?.name !== undefined && val.id === undefined) {
|
|
407
|
+
const resolved = await resolveEnum(field, val.name, client);
|
|
408
|
+
if (typeof resolved !== 'string')
|
|
409
|
+
patch[field] = resolved;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const result = await client.patch(`issues/${id}`, patch);
|
|
308
413
|
return {
|
|
309
414
|
content: [{ type: 'text', text: JSON.stringify(result.issue ?? result, null, 2) }],
|
|
310
415
|
};
|