@dpesch/mantisbt-mcp-server 1.3.1 → 1.4.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
@@ -7,6 +7,16 @@ This project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.4.0] – 2026-03-17
11
+
12
+ ### Added
13
+ - `get_issue_enums` now includes a `label` field in each enum entry when it differs from `name`. On localized MantisBT installations (e.g. German UI) this provides a translation table from the UI language back to the API name/id: `{"id": 10, "name": "new", "label": "Neu"}`. When `label` and `name` are identical the field is omitted to keep the output compact. The tool description also clarifies that `name` may itself be localized on installations where enum values have been customized at the database level.
14
+
15
+ ### Fixed
16
+ - `list_issues`: `assigned_to`, `reporter_id`, and `status` filters now reliably return matching issues regardless of the requested `page_size`. Previously, the tool fetched exactly `page_size` items from the API before filtering — so a small `page_size` combined with an active filter returned zero results if the matching issues were not at the top of the unfiltered list. The tool now internally fetches batches of 50 (API maximum) and scans up to 500 issues until enough matching results are found. All filter parameters are still forwarded to the API as a hint for installations that support server-side filtering.
17
+
18
+ ---
19
+
10
20
  ## [1.3.1] – 2026-03-16
11
21
 
12
22
  ### Fixed
@@ -66,12 +66,12 @@ Common option names:
66
66
  // ---------------------------------------------------------------------------
67
67
  server.registerTool('get_issue_enums', {
68
68
  title: 'Get Issue Enum Values',
69
- description: `Return valid ID and name pairs for all issue enum fields.
69
+ description: `Return valid ID, name, and (if available) localized label for all issue enum fields.
70
70
 
71
71
  Use this tool before creating or updating issues to look up the correct value
72
72
  for severity, status, priority, resolution, or reproducibility.
73
73
 
74
- Example response:
74
+ Example response (English installation):
75
75
  {
76
76
  "severity": [{"id": 10, "name": "feature"}, {"id": 50, "name": "minor"}, ...],
77
77
  "status": [{"id": 10, "name": "new"}, {"id": 20, "name": "feedback"}, ...],
@@ -80,7 +80,30 @@ Example response:
80
80
  "reproducibility": [{"id": 10, "name": "always"}, {"id": 70, "name": "have not tried"}, ...]
81
81
  }
82
82
 
83
- The "name" field is the value to pass to create_issue or update_issue.`,
83
+ Example response (localized installation, e.g. German):
84
+ {
85
+ "status": [
86
+ {"id": 10, "name": "new", "label": "Neu"},
87
+ {"id": 20, "name": "feedback", "label": "Feedback"},
88
+ {"id": 30, "name": "acknowledged", "label": "Bestätigt"},
89
+ ...
90
+ ],
91
+ ...
92
+ }
93
+
94
+ Fields:
95
+ - "id" — numeric ID accepted by the API
96
+ - "name" — the API value to pass to create_issue or update_issue; normally English, but may be
97
+ localized if the installation has customized enum values in the database
98
+ - "label" — localized display label shown in the UI (only present when it differs from "name")
99
+
100
+ Always pass either the "id" or the "name" value to create_issue or update_issue — never the "label".
101
+ Use the "label" to map user input in the UI language back to the correct "name"/"id" for the API.
102
+
103
+ Note: on some installations enum values are customized at the database level. In that case "name"
104
+ itself may be localized (e.g. "kleinerer Fehler" instead of "minor") and no "label" will be present
105
+ because there is no separate English original. The "name" value returned is always the correct one
106
+ to use for API calls — regardless of language.`,
84
107
  inputSchema: z.object({}),
85
108
  annotations: {
86
109
  readOnlyHint: true,
@@ -111,7 +134,12 @@ The "name" field is the value to pass to create_issue or update_issue.`,
111
134
  enums[key] = parseEnumString(value);
112
135
  }
113
136
  else if (Array.isArray(value)) {
114
- enums[key] = value.map(({ id, name }) => ({ id, name }));
137
+ enums[key] = value.map(({ id, name, label }) => {
138
+ const entry = { id, name };
139
+ if (label && label !== name)
140
+ entry.label = label;
141
+ return entry;
142
+ });
115
143
  }
116
144
  }
117
145
  return {
@@ -40,7 +40,7 @@ export function registerIssueTools(server, client) {
40
40
  // ---------------------------------------------------------------------------
41
41
  server.registerTool('list_issues', {
42
42
  title: 'List Issues',
43
- description: 'List MantisBT issues with optional filtering. Returns a paginated list of issues. Use the "select" parameter to limit returned fields and reduce response size significantly.',
43
+ description: 'List MantisBT issues with optional filtering. Returns a paginated list of issues. Use the "select" parameter to limit returned fields and reduce response size significantly.\n\nNote: "assigned_to", "reporter_id", and "status" filters are applied client-side (the MantisBT REST API does not reliably 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.',
44
44
  inputSchema: z.object({
45
45
  project_id: z.coerce.number().int().positive().optional().describe('Filter by project ID'),
46
46
  page: z.coerce.number().int().positive().default(1).describe('Page number (default: 1)'),
@@ -60,9 +60,7 @@ export function registerIssueTools(server, client) {
60
60
  },
61
61
  }, async ({ project_id, page, page_size, assigned_to, reporter_id, filter_id, sort, direction, select, status }) => {
62
62
  try {
63
- const params = {
64
- page,
65
- page_size,
63
+ const baseParams = {
66
64
  project_id,
67
65
  assigned_to,
68
66
  reporter_id,
@@ -71,19 +69,57 @@ export function registerIssueTools(server, client) {
71
69
  direction,
72
70
  select,
73
71
  };
74
- const result = await client.get('issues', params);
75
- if (status && result.issues) {
76
- const statusLower = status.toLowerCase();
77
- result.issues = result.issues.filter(issue => {
78
- if (!issue.status)
79
- return false;
80
- if (statusLower === 'open')
81
- return (issue.status.id ?? 0) < MANTIS_RESOLVED_STATUS_ID;
82
- return issue.status.name?.toLowerCase() === statusLower;
72
+ const needsClientFilter = status !== undefined || assigned_to !== undefined || reporter_id !== undefined;
73
+ if (!needsClientFilter) {
74
+ // No client-side filtering — single API call, pass pagination as-is
75
+ const result = await client.get('issues', { ...baseParams, page, page_size });
76
+ return {
77
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
78
+ };
79
+ }
80
+ // Client-side filtering active: scan multiple API pages until we have
81
+ // enough matching results for the requested logical page.
82
+ const API_PAGE_SIZE = 50; // always fetch max to minimise round-trips
83
+ const MAX_API_PAGES = 10; // hard cap: scan at most 500 issues
84
+ const neededTotal = page * page_size; // need this many matches to serve page N
85
+ const matching = [];
86
+ let serverPage = 1;
87
+ let hasMore = true;
88
+ const statusLower = status?.toLowerCase();
89
+ while (matching.length < neededTotal && serverPage <= MAX_API_PAGES && hasMore) {
90
+ const batch = await client.get('issues', {
91
+ ...baseParams,
92
+ page: serverPage,
93
+ page_size: API_PAGE_SIZE,
83
94
  });
95
+ const issues = batch.issues ?? [];
96
+ hasMore = issues.length === API_PAGE_SIZE;
97
+ for (const issue of issues) {
98
+ if (statusLower) {
99
+ if (!issue.status)
100
+ continue;
101
+ if (statusLower === 'open') {
102
+ if ((issue.status.id ?? 0) >= MANTIS_RESOLVED_STATUS_ID)
103
+ continue;
104
+ }
105
+ else if (issue.status.name?.toLowerCase() !== statusLower) {
106
+ continue;
107
+ }
108
+ }
109
+ if (assigned_to !== undefined && issue.handler?.id !== assigned_to)
110
+ continue;
111
+ if (reporter_id !== undefined && issue.reporter?.id !== reporter_id)
112
+ continue;
113
+ matching.push(issue);
114
+ }
115
+ serverPage++;
84
116
  }
117
+ const start = (page - 1) * page_size;
85
118
  return {
86
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
119
+ content: [{
120
+ type: 'text',
121
+ text: JSON.stringify({ issues: matching.slice(start, start + page_size) }, null, 2),
122
+ }],
87
123
  };
88
124
  }
89
125
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dpesch/mantisbt-mcp-server",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "MCP server for MantisBT REST API – read and manage bug tracker issues",
5
5
  "author": "Dominik Pesch",
6
6
  "license": "MIT",
@@ -1,4 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ type EnumResult = Record<string, Array<{ id: number; name: string; label?: string }>>;
2
4
  import { readFileSync } from 'node:fs';
3
5
  import { join, dirname } from 'node:path';
4
6
  import { fileURLToPath } from 'node:url';
@@ -59,38 +61,63 @@ describe('get_issue_enums', () => {
59
61
  expect(mockServer.hasToolRegistered('get_issue_enums')).toBe(true);
60
62
  });
61
63
 
62
- it('gibt strukturierte Enum-Arrays für alle 5 Felder zurück', async () => {
63
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(enumFixture)));
64
-
65
- const result = await mockServer.callTool('get_issue_enums', {});
66
-
67
- expect(result.isError).toBeUndefined();
68
- const parsed = JSON.parse(result.content[0]!.text) as Record<string, Array<{ id: number; name: string }>>;
69
-
70
- expect(Array.isArray(parsed.severity)).toBe(true);
71
- expect(Array.isArray(parsed.status)).toBe(true);
72
- expect(Array.isArray(parsed.priority)).toBe(true);
73
- expect(Array.isArray(parsed.resolution)).toBe(true);
74
- expect(Array.isArray(parsed.reproducibility)).toBe(true);
75
- });
76
-
77
- it('parst Array-Format (MantisBT 2.x): id und name korrekt, label wird verworfen', async () => {
78
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(enumFixture)));
79
-
80
- const result = await mockServer.callTool('get_issue_enums', {});
81
-
82
- const parsed = JSON.parse(result.content[0]!.text) as Record<string, Array<{ id: number; name: string }>>;
83
-
84
- // Werte aus der echten Fixture prüfen
85
- expect(parsed.severity).toContainEqual({ id: 50, name: 'kleinerer Fehler' });
86
- expect(parsed.severity).toContainEqual({ id: 80, name: 'Blocker' });
87
- expect(parsed.severity).toContainEqual({ id: 200, name: 'Technische Schuld' });
88
- expect(parsed.status).toContainEqual({ id: 10, name: 'new' });
89
- expect(parsed.status).toContainEqual({ id: 80, name: 'resolved' });
90
- expect(parsed.priority).toContainEqual({ id: 30, name: 'normal' });
91
- expect(parsed.reproducibility).toContainEqual({ id: 70, name: 'have not tried' });
92
- // label darf nicht im Output enthalten sein
93
- expect(Object.keys(parsed.severity[0]!)).toEqual(['id', 'name']);
64
+ describe('mit Array-Format (MantisBT 2.x)', () => {
65
+ let parsed: EnumResult;
66
+
67
+ beforeEach(async () => {
68
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(enumFixture)));
69
+ const result = await mockServer.callTool('get_issue_enums', {});
70
+ parsed = JSON.parse(result.content[0]!.text) as EnumResult;
71
+ });
72
+
73
+ it('gibt strukturierte Enum-Arrays für alle 5 Felder zurück', () => {
74
+ expect(Array.isArray(parsed.severity)).toBe(true);
75
+ expect(Array.isArray(parsed.status)).toBe(true);
76
+ expect(Array.isArray(parsed.priority)).toBe(true);
77
+ expect(Array.isArray(parsed.resolution)).toBe(true);
78
+ expect(Array.isArray(parsed.reproducibility)).toBe(true);
79
+ });
80
+
81
+ it('id und name korrekt für alle Felder', () => {
82
+ expect(parsed.severity).toContainEqual(expect.objectContaining({ id: 50, name: 'kleinerer Fehler' }));
83
+ expect(parsed.severity).toContainEqual(expect.objectContaining({ id: 80, name: 'Blocker' }));
84
+ expect(parsed.severity).toContainEqual(expect.objectContaining({ id: 200, name: 'Technische Schuld' }));
85
+ expect(parsed.status).toContainEqual(expect.objectContaining({ id: 10, name: 'new' }));
86
+ expect(parsed.status).toContainEqual(expect.objectContaining({ id: 80, name: 'resolved' }));
87
+ expect(parsed.priority).toContainEqual(expect.objectContaining({ id: 30, name: 'normal' }));
88
+ expect(parsed.reproducibility).toContainEqual(expect.objectContaining({ id: 70, name: 'have not tried' }));
89
+ });
90
+
91
+ it('label wird ausgegeben wenn er sich von name unterscheidet (lokalisierte Installation)', () => {
92
+ // status: name="new", label="neu" label muss im Output enthalten sein
93
+ expect(parsed.status).toContainEqual({ id: 10, name: 'new', label: 'neu' });
94
+ expect(parsed.status).toContainEqual({ id: 80, name: 'resolved', label: 'erledigt' });
95
+ // priority: name="none", label="keine" → label muss im Output enthalten sein
96
+ expect(parsed.priority).toContainEqual({ id: 10, name: 'none', label: 'keine' });
97
+ // reproducibility: name="always", label="immer" → label muss im Output enthalten sein
98
+ expect(parsed.reproducibility).toContainEqual({ id: 10, name: 'always', label: 'immer' });
99
+ });
100
+
101
+ it('label wird weggelassen wenn er identisch mit name ist', () => {
102
+ // severity: name === label (z.B. "Feature-Wunsch" === "Feature-Wunsch") → kein label-Feld
103
+ const severityFirst = parsed.severity.find(e => e.id === 10)!;
104
+ expect(severityFirst).toEqual({ id: 10, name: 'Feature-Wunsch' });
105
+ expect(severityFirst).not.toHaveProperty('label');
106
+
107
+ // priority: name="normal", label="normal" → kein label-Feld
108
+ const priorityNormal = parsed.priority.find(e => e.id === 30)!;
109
+ expect(priorityNormal).toEqual({ id: 30, name: 'normal' });
110
+ expect(priorityNormal).not.toHaveProperty('label');
111
+ });
112
+
113
+ it('fragt alle 5 Enum-Optionen ab', () => {
114
+ const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
115
+ expect(calledUrl).toContain('severity_enum_string');
116
+ expect(calledUrl).toContain('status_enum_string');
117
+ expect(calledUrl).toContain('priority_enum_string');
118
+ expect(calledUrl).toContain('resolution_enum_string');
119
+ expect(calledUrl).toContain('reproducibility_enum_string');
120
+ });
94
121
  });
95
122
 
96
123
  it('parst String-Format (Legacy): "id:name,..."-Strings korrekt', async () => {
@@ -98,26 +125,13 @@ describe('get_issue_enums', () => {
98
125
 
99
126
  const result = await mockServer.callTool('get_issue_enums', {});
100
127
 
101
- const parsed = JSON.parse(result.content[0]!.text) as Record<string, Array<{ id: number; name: string }>>;
128
+ const parsed = JSON.parse(result.content[0]!.text) as EnumResult;
102
129
 
103
130
  expect(parsed.severity).toContainEqual({ id: 50, name: 'minor' });
104
131
  expect(parsed.status).toContainEqual({ id: 80, name: 'resolved' });
105
132
  expect(parsed.reproducibility).toContainEqual({ id: 70, name: 'have not tried' });
106
133
  });
107
134
 
108
- it('fragt alle 5 Enum-Optionen ab', async () => {
109
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(enumFixture)));
110
-
111
- await mockServer.callTool('get_issue_enums', {});
112
-
113
- const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
114
- expect(calledUrl).toContain('severity_enum_string');
115
- expect(calledUrl).toContain('status_enum_string');
116
- expect(calledUrl).toContain('priority_enum_string');
117
- expect(calledUrl).toContain('resolution_enum_string');
118
- expect(calledUrl).toContain('reproducibility_enum_string');
119
- });
120
-
121
135
  it('gibt isError: true bei API-Fehler zurück', async () => {
122
136
  vi.mocked(fetch).mockResolvedValue(makeResponse(403, JSON.stringify({ message: 'Access denied' })));
123
137
 
@@ -215,6 +215,69 @@ describe('list_issues', () => {
215
215
  }
216
216
  });
217
217
 
218
+ it('assigned_to scans multiple API pages (small page_size does not miss results)', async () => {
219
+ // Regression: previously fetched only page_size items from the API before filtering,
220
+ // so assigned_to:X with page_size:1 returned 0 results when user's issue was not
221
+ // in the first page returned by the server.
222
+ // Now the tool always fetches API_PAGE_SIZE=50 internally when filters are active.
223
+ const fixtureWithUser51: typeof listIssuesFixture = {
224
+ ...listIssuesFixture,
225
+ issues: [
226
+ // first "slot" has a different user — previously this would have been the only
227
+ // item fetched with page_size:1, causing a false empty result
228
+ listIssuesFixture.issues.find(i => (i as { handler?: { id: number } }).handler?.id === 52)!,
229
+ ...listIssuesFixture.issues.filter(i => (i as { handler?: { id: number } }).handler?.id === 51),
230
+ ].filter(Boolean),
231
+ };
232
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(fixtureWithUser51)));
233
+
234
+ const result = await mockServer.callTool('list_issues', { assigned_to: 51, page: 1, page_size: 1 });
235
+
236
+ expect(result.isError).toBeUndefined();
237
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ handler: { id: number } }> };
238
+ // Must find results for user 51 despite page_size:1
239
+ expect(parsed.issues.length).toBeGreaterThan(0);
240
+ parsed.issues.forEach(issue => expect(issue.handler.id).toBe(51));
241
+ });
242
+
243
+ it('assigned_to filters issues by handler.id (client-side)', async () => {
244
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
245
+
246
+ const result = await mockServer.callTool('list_issues', { assigned_to: 51, page: 1, page_size: 50 });
247
+
248
+ expect(result.isError).toBeUndefined();
249
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ handler: { id: number } }> };
250
+ const expectedCount = listIssuesFixture.issues.filter(i => (i as { handler?: { id: number } }).handler?.id === 51).length;
251
+ expect(parsed.issues).toHaveLength(expectedCount);
252
+ parsed.issues.forEach(issue => {
253
+ expect(issue.handler.id).toBe(51);
254
+ });
255
+ });
256
+
257
+ it('assigned_to still sends the parameter to the API (server-side hint)', async () => {
258
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
259
+
260
+ await mockServer.callTool('list_issues', { assigned_to: 51, page: 1, page_size: 50 });
261
+
262
+ const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
263
+ const url = new URL(calledUrl);
264
+ expect(url.searchParams.get('assigned_to')).toBe('51');
265
+ });
266
+
267
+ it('reporter_id filters issues by reporter.id (client-side)', async () => {
268
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
269
+
270
+ const result = await mockServer.callTool('list_issues', { reporter_id: 52, page: 1, page_size: 50 });
271
+
272
+ expect(result.isError).toBeUndefined();
273
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ reporter: { id: number } }> };
274
+ const expectedCount = listIssuesFixture.issues.filter(i => (i as { reporter?: { id: number } }).reporter?.id === 52).length;
275
+ expect(parsed.issues).toHaveLength(expectedCount);
276
+ parsed.issues.forEach(issue => {
277
+ expect(issue.reporter.id).toBe(52);
278
+ });
279
+ });
280
+
218
281
  it('status filter is case-insensitive', async () => {
219
282
  vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
220
283
  const resultUpper = await mockServer.callTool('list_issues', { status: 'OPEN', page: 1, page_size: 50 });