@dpesch/mantisbt-mcp-server 1.3.0 → 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,23 @@ 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
+
20
+ ## [1.3.1] – 2026-03-16
21
+
22
+ ### Fixed
23
+ - `get_issue_enums` returned an empty object `{}` on MantisBT 2.x installations. The MantisBT REST API returns enum config values as pre-parsed `[{id, name, label}]` arrays, not as legacy `"id:name,..."` strings. The handler now covers both formats; the `label` field is stripped from the output.
24
+
25
+ ---
26
+
10
27
  ## [1.3.0] – 2026-03-16
11
28
 
12
29
  ### Added
package/dist/constants.js CHANGED
@@ -15,6 +15,17 @@ export const RELATIONSHIP_TYPES = {
15
15
  // MantisBT default status ID for "resolved". Issues with status.id strictly
16
16
  // below this value are considered open (new/feedback/acknowledged/confirmed/assigned).
17
17
  export const MANTIS_RESOLVED_STATUS_ID = 80;
18
+ // ---------------------------------------------------------------------------
19
+ // Issue enum config option names
20
+ // ---------------------------------------------------------------------------
21
+ export const ISSUE_ENUM_OPTIONS = [
22
+ 'severity_enum_string',
23
+ 'status_enum_string',
24
+ 'priority_enum_string',
25
+ 'resolution_enum_string',
26
+ 'reproducibility_enum_string',
27
+ ];
28
+ // ---------------------------------------------------------------------------
18
29
  export const STATUS_NAMES = [
19
30
  'new',
20
31
  'feedback',
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { ISSUE_ENUM_OPTIONS } from '../constants.js';
2
3
  import { getVersionHint } from '../version-hint.js';
3
4
  function errorText(msg) {
4
5
  const vh = getVersionHint();
@@ -65,12 +66,12 @@ Common option names:
65
66
  // ---------------------------------------------------------------------------
66
67
  server.registerTool('get_issue_enums', {
67
68
  title: 'Get Issue Enum Values',
68
- 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.
69
70
 
70
71
  Use this tool before creating or updating issues to look up the correct value
71
72
  for severity, status, priority, resolution, or reproducibility.
72
73
 
73
- Example response:
74
+ Example response (English installation):
74
75
  {
75
76
  "severity": [{"id": 10, "name": "feature"}, {"id": 50, "name": "minor"}, ...],
76
77
  "status": [{"id": 10, "name": "new"}, {"id": 20, "name": "feedback"}, ...],
@@ -79,7 +80,30 @@ Example response:
79
80
  "reproducibility": [{"id": 10, "name": "always"}, {"id": 70, "name": "have not tried"}, ...]
80
81
  }
81
82
 
82
- 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.`,
83
107
  inputSchema: z.object({}),
84
108
  annotations: {
85
109
  readOnlyHint: true,
@@ -88,15 +112,8 @@ The "name" field is the value to pass to create_issue or update_issue.`,
88
112
  },
89
113
  }, async () => {
90
114
  try {
91
- const enumOptions = [
92
- 'severity_enum_string',
93
- 'status_enum_string',
94
- 'priority_enum_string',
95
- 'resolution_enum_string',
96
- 'reproducibility_enum_string',
97
- ];
98
115
  const params = {};
99
- enumOptions.forEach((opt, i) => {
116
+ ISSUE_ENUM_OPTIONS.forEach((opt, i) => {
100
117
  params[`option[${i}]`] = opt;
101
118
  });
102
119
  const result = await client.get('config', params);
@@ -111,9 +128,19 @@ The "name" field is the value to pass to create_issue or update_issue.`,
111
128
  const enums = {};
112
129
  for (const { option, value } of configs) {
113
130
  const key = keyMap[option];
114
- if (key && typeof value === 'string') {
131
+ if (!key)
132
+ continue;
133
+ if (typeof value === 'string') {
115
134
  enums[key] = parseEnumString(value);
116
135
  }
136
+ else if (Array.isArray(value)) {
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
+ });
143
+ }
117
144
  }
118
145
  return {
119
146
  content: [{ type: 'text', text: JSON.stringify(enums, null, 2) }],
@@ -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.0",
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",
@@ -2,6 +2,7 @@ import { writeFileSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { MantisClient } from '../src/client.js';
5
+ import { ISSUE_ENUM_OPTIONS } from '../src/constants.js';
5
6
 
6
7
  const __filename = fileURLToPath(import.meta.url);
7
8
  const __dirname = dirname(__filename);
@@ -124,6 +125,16 @@ async function recordFixtures(): Promise<void> {
124
125
  }
125
126
  }
126
127
 
128
+ // GET config — enum values for severity, status, priority, resolution, reproducibility
129
+ try {
130
+ const enumParams: Record<string, string> = {};
131
+ ISSUE_ENUM_OPTIONS.forEach((opt, i) => { enumParams[`option[${i}]`] = opt; });
132
+ const configResult = await client.get<unknown>('config', enumParams);
133
+ saveFixture('get_issue_enums.json', configResult);
134
+ } catch (err) {
135
+ console.error('Failed to fetch config enums:', err instanceof Error ? err.message : String(err));
136
+ }
137
+
127
138
  // GET projects/{id}/versions + categories
128
139
  if (firstProjectId !== undefined) {
129
140
  const versionProjectId = projectIdWithVersions ?? firstProjectId;
@@ -0,0 +1,67 @@
1
+ {
2
+ "configs": [
3
+ {
4
+ "option": "severity_enum_string",
5
+ "value": [
6
+ { "id": 10, "name": "Feature-Wunsch", "label": "Feature-Wunsch" },
7
+ { "id": 20, "name": "Trivial", "label": "Trivial" },
8
+ { "id": 30, "name": "Fehler im Text", "label": "Fehler im Text" },
9
+ { "id": 40, "name": "Unschönheit", "label": "Unschönheit" },
10
+ { "id": 50, "name": "kleinerer Fehler", "label": "kleinerer Fehler" },
11
+ { "id": 60, "name": "schwerer Fehler", "label": "schwerer Fehler" },
12
+ { "id": 70, "name": "Absturz", "label": "Absturz" },
13
+ { "id": 80, "name": "Blocker", "label": "Blocker" },
14
+ { "id": 200, "name": "Technische Schuld", "label": "Technische Schuld" },
15
+ { "id": 210, "name": "Wartung", "label": "Wartung" }
16
+ ]
17
+ },
18
+ {
19
+ "option": "status_enum_string",
20
+ "value": [
21
+ { "id": 10, "name": "new", "label": "neu" },
22
+ { "id": 20, "name": "feedback", "label": "Rückmeldung" },
23
+ { "id": 30, "name": "acknowledged", "label": "anerkannt" },
24
+ { "id": 40, "name": "confirmed", "label": "bestätigt" },
25
+ { "id": 50, "name": "assigned", "label": "zugewiesen" },
26
+ { "id": 80, "name": "resolved", "label": "erledigt" },
27
+ { "id": 90, "name": "closed", "label": "geschlossen" }
28
+ ]
29
+ },
30
+ {
31
+ "option": "priority_enum_string",
32
+ "value": [
33
+ { "id": 10, "name": "none", "label": "keine" },
34
+ { "id": 20, "name": "low", "label": "niedrig" },
35
+ { "id": 30, "name": "normal", "label": "normal" },
36
+ { "id": 40, "name": "high", "label": "hoch" },
37
+ { "id": 50, "name": "urgent", "label": "dringend" },
38
+ { "id": 60, "name": "immediate", "label": "sofort" }
39
+ ]
40
+ },
41
+ {
42
+ "option": "resolution_enum_string",
43
+ "value": [
44
+ { "id": 10, "name": "open", "label": "offen" },
45
+ { "id": 20, "name": "fixed", "label": "erledigt" },
46
+ { "id": 30, "name": "reopened", "label": "wiedereröffnet" },
47
+ { "id": 40, "name": "unable to duplicate", "label": "nicht reproduzierbar" },
48
+ { "id": 50, "name": "not fixable", "label": "unlösbar" },
49
+ { "id": 60, "name": "duplicate", "label": "doppelt" },
50
+ { "id": 70, "name": "not a bug", "label": "keine Änderung notwendig" },
51
+ { "id": 80, "name": "suspended", "label": "aufgeschoben" },
52
+ { "id": 90, "name": "wont fix", "label": "wird nicht behoben" }
53
+ ]
54
+ },
55
+ {
56
+ "option": "reproducibility_enum_string",
57
+ "value": [
58
+ { "id": 10, "name": "always", "label": "immer" },
59
+ { "id": 30, "name": "sometimes", "label": "manchmal" },
60
+ { "id": 50, "name": "random", "label": "zufällig" },
61
+ { "id": 70, "name": "have not tried", "label": "nicht getestet" },
62
+ { "id": 90, "name": "unable to duplicate", "label": "nicht reproduzierbar" },
63
+ { "id": 100, "name": "N/A", "label": "N/A" }
64
+ ]
65
+ }
66
+ ]
67
+ }
@@ -1,23 +1,41 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ type EnumResult = Record<string, Array<{ id: number; name: string; label?: string }>>;
4
+ import { readFileSync } from 'node:fs';
5
+ import { join, dirname } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
2
7
  import { MantisClient } from '../../src/client.js';
3
8
  import { MetadataCache } from '../../src/cache.js';
4
9
  import { registerConfigTools } from '../../src/tools/config.js';
5
10
  import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
6
11
 
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+ const fixturesDir = join(__dirname, '..', 'fixtures');
15
+
7
16
  // ---------------------------------------------------------------------------
8
- // Setup
17
+ // Fixtures
9
18
  // ---------------------------------------------------------------------------
10
19
 
11
- const ENUM_FIXTURE = {
20
+ const enumFixture = JSON.parse(
21
+ readFileSync(join(fixturesDir, 'get_issue_enums.json'), 'utf-8')
22
+ ) as { configs: Array<{ option: string; value: Array<{ id: number; name: string; label: string }> }> };
23
+
24
+ // Legacy: some MantisBT versions return value as a comma-separated "id:name" string
25
+ const ENUM_FIXTURE_STRING = {
12
26
  configs: [
13
- { option: 'severity_enum_string', value: '10:feature,20:trivial,30:text,40:tweak,50:minor,60:major,70:crash,80:block' },
14
- { option: 'status_enum_string', value: '10:new,20:feedback,30:acknowledged,40:confirmed,50:assigned,80:resolved,90:closed' },
15
- { option: 'priority_enum_string', value: '10:none,20:low,30:normal,40:high,50:urgent,60:immediate' },
16
- { option: 'resolution_enum_string', value: '10:open,20:fixed,30:reopened,40:unable to duplicate,50:not fixable,60:duplicate,70:no change required,80:suspended,90:wont fix' },
17
- { option: 'reproducibility_enum_string', value: '10:always,30:sometimes,50:random,70:have not tried,90:unable to reproduce,100:N/A' },
27
+ { option: 'severity_enum_string', value: '10:feature,50:minor,80:block' },
28
+ { option: 'status_enum_string', value: '10:new,80:resolved,90:closed' },
29
+ { option: 'priority_enum_string', value: '10:none,30:normal,60:immediate' },
30
+ { option: 'resolution_enum_string', value: '10:open,20:fixed' },
31
+ { option: 'reproducibility_enum_string', value: '10:always,70:have not tried' },
18
32
  ],
19
33
  };
20
34
 
35
+ // ---------------------------------------------------------------------------
36
+ // Setup
37
+ // ---------------------------------------------------------------------------
38
+
21
39
  let mockServer: MockMcpServer;
22
40
  let client: MantisClient;
23
41
  let cache: MetadataCache;
@@ -43,49 +61,77 @@ describe('get_issue_enums', () => {
43
61
  expect(mockServer.hasToolRegistered('get_issue_enums')).toBe(true);
44
62
  });
45
63
 
46
- it('gibt strukturierte Enum-Arrays für alle 5 Felder zurück', async () => {
47
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(ENUM_FIXTURE)));
48
-
49
- const result = await mockServer.callTool('get_issue_enums', {});
50
-
51
- expect(result.isError).toBeUndefined();
52
- const parsed = JSON.parse(result.content[0]!.text) as Record<string, Array<{ id: number; name: string }>>;
53
-
54
- expect(Array.isArray(parsed.severity)).toBe(true);
55
- expect(Array.isArray(parsed.status)).toBe(true);
56
- expect(Array.isArray(parsed.priority)).toBe(true);
57
- expect(Array.isArray(parsed.resolution)).toBe(true);
58
- expect(Array.isArray(parsed.reproducibility)).toBe(true);
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
+ });
59
121
  });
60
122
 
61
- it('parst id und name korrekt', async () => {
62
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(ENUM_FIXTURE)));
123
+ it('parst String-Format (Legacy): "id:name,..."-Strings korrekt', async () => {
124
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(ENUM_FIXTURE_STRING)));
63
125
 
64
126
  const result = await mockServer.callTool('get_issue_enums', {});
65
127
 
66
- 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;
67
129
 
68
130
  expect(parsed.severity).toContainEqual({ id: 50, name: 'minor' });
69
- expect(parsed.severity).toContainEqual({ id: 80, name: 'block' });
70
- expect(parsed.status).toContainEqual({ id: 10, name: 'new' });
71
131
  expect(parsed.status).toContainEqual({ id: 80, name: 'resolved' });
72
- expect(parsed.priority).toContainEqual({ id: 30, name: 'normal' });
73
132
  expect(parsed.reproducibility).toContainEqual({ id: 70, name: 'have not tried' });
74
133
  });
75
134
 
76
- it('fragt alle 5 Enum-Optionen ab', async () => {
77
- vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(ENUM_FIXTURE)));
78
-
79
- await mockServer.callTool('get_issue_enums', {});
80
-
81
- const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
82
- expect(calledUrl).toContain('severity_enum_string');
83
- expect(calledUrl).toContain('status_enum_string');
84
- expect(calledUrl).toContain('priority_enum_string');
85
- expect(calledUrl).toContain('resolution_enum_string');
86
- expect(calledUrl).toContain('reproducibility_enum_string');
87
- });
88
-
89
135
  it('gibt isError: true bei API-Fehler zurück', async () => {
90
136
  vi.mocked(fetch).mockResolvedValue(makeResponse(403, JSON.stringify({ message: 'Access denied' })));
91
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 });