@dpesch/mantisbt-mcp-server 1.0.3 → 1.1.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.
@@ -0,0 +1,17 @@
1
+ ## Description
2
+
3
+ <!-- Why is this change needed? What problem does it solve? -->
4
+
5
+ ## Changes
6
+
7
+ <!-- Brief summary of what was changed -->
8
+
9
+ ## Testing
10
+
11
+ <!-- How did you test this? -->
12
+
13
+ ---
14
+
15
+ > **Note:** This repository is a public mirror. Your PR will be reviewed and
16
+ > integrated via cherry-pick — it will not be merged directly through Codeberg.
17
+ > You will be credited in the commit message and CHANGELOG.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ This project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.1.0] – 2026-03-15
11
+
12
+ ### Added
13
+ - `list_issues`: new optional `select` parameter — passes a comma-separated field list to the MantisBT `select` query parameter for server-side field projection. Significantly reduces response size when only a subset of fields is needed (e.g. `"id,summary,status,priority,handler,updated_at"`).
14
+ - `list_issues`: new optional `status` parameter — client-side filter by status name (e.g. `"new"`, `"assigned"`, `"resolved"`) or the shorthand `"open"` for all statuses with id < 80. Note: applied after fetching, so a page may contain fewer results than `page_size` when active.
15
+ - New tool `get_issue_fields`: returns all field names valid for the `select` parameter. Fetches a sample issue to reflect the server's active configuration, merges with fields MantisBT omits when empty (notes, attachments, etc.), and caches the result.
16
+
17
+ ---
18
+
10
19
  ## [1.0.3] – 2026-03-15
11
20
 
12
21
  ### Fixed
package/README.de.md CHANGED
@@ -1,6 +1,5 @@
1
1
  # MantisBT MCP Server
2
2
 
3
- [![CI](https://codeberg.org/dpesch/mantisbt-mcp-server/actions/workflows/ci.yml/badge.svg)](https://codeberg.org/dpesch/mantisbt-mcp-server/actions)
4
3
  [![npm version](https://img.shields.io/npm/v/@dpesch/mantisbt-mcp-server)](https://www.npmjs.com/package/@dpesch/mantisbt-mcp-server)
5
4
  [![license](https://img.shields.io/npm/l/@dpesch/mantisbt-mcp-server)](LICENSE)
6
5
 
@@ -89,7 +88,7 @@ Falls keine Umgebungsvariablen gesetzt sind, wird `~/.claude/mantis.json` ausgel
89
88
  | Tool | Beschreibung |
90
89
  |---|---|
91
90
  | `get_issue` | Ein Issue anhand seiner ID abrufen |
92
- | `list_issues` | Issues nach Projekt, Status, Autor u.v.m. filtern |
91
+ | `list_issues` | Issues nach Projekt, Status, Autor u.v.m. filtern; optionales `select` für Feldprojektion und `status` für clientseitige Statusfilterung |
93
92
  | `create_issue` | Neues Issue anlegen |
94
93
  | `update_issue` | Bestehendes Issue bearbeiten |
95
94
  | `delete_issue` | Issue löschen |
@@ -141,6 +140,7 @@ Falls keine Umgebungsvariablen gesetzt sind, wird `~/.claude/mantis.json` ausgel
141
140
 
142
141
  | Tool | Beschreibung |
143
142
  |---|---|
143
+ | `get_issue_fields` | Alle gültigen Feldnamen für den `select`-Parameter von `list_issues` zurückgeben |
144
144
  | `get_metadata` | Gecachte Metadaten abrufen (Projekte, Benutzer, Versionen, Kategorien) |
145
145
  | `sync_metadata` | Metadaten-Cache neu befüllen |
146
146
  | `list_filters` | Gespeicherte Filter auflisten |
package/README.md CHANGED
@@ -1,6 +1,5 @@
1
1
  # MantisBT MCP Server
2
2
 
3
- [![CI](https://codeberg.org/dpesch/mantisbt-mcp-server/actions/workflows/ci.yml/badge.svg)](https://codeberg.org/dpesch/mantisbt-mcp-server/actions)
4
3
  [![npm version](https://img.shields.io/npm/v/@dpesch/mantisbt-mcp-server)](https://www.npmjs.com/package/@dpesch/mantisbt-mcp-server)
5
4
  [![license](https://img.shields.io/npm/l/@dpesch/mantisbt-mcp-server)](LICENSE)
6
5
 
@@ -89,7 +88,7 @@ If no environment variables are set, `~/.claude/mantis.json` is read:
89
88
  | Tool | Description |
90
89
  |---|---|
91
90
  | `get_issue` | Retrieve an issue by its numeric ID |
92
- | `list_issues` | Filter issues by project, status, author, and more |
91
+ | `list_issues` | Filter issues by project, status, author, and more; optional `select` for field projection and `status` for client-side status filtering |
93
92
  | `create_issue` | Create a new issue |
94
93
  | `update_issue` | Update an existing issue |
95
94
  | `delete_issue` | Delete an issue |
@@ -141,6 +140,7 @@ If no environment variables are set, `~/.claude/mantis.json` is read:
141
140
 
142
141
  | Tool | Description |
143
142
  |---|---|
143
+ | `get_issue_fields` | Return all field names valid for the `select` parameter of `list_issues` |
144
144
  | `get_metadata` | Retrieve cached metadata (projects, users, versions, categories) |
145
145
  | `sync_metadata` | Refresh the metadata cache |
146
146
  | `list_filters` | List saved filters |
package/dist/cache.js CHANGED
@@ -5,11 +5,13 @@ import { join } from 'node:path';
5
5
  // ---------------------------------------------------------------------------
6
6
  export class MetadataCache {
7
7
  filePath;
8
+ issueFieldsFilePath;
8
9
  ttlSeconds;
9
10
  cacheDir;
10
11
  constructor(cacheDir, ttlSeconds) {
11
12
  this.cacheDir = cacheDir;
12
13
  this.filePath = join(cacheDir, 'metadata.json');
14
+ this.issueFieldsFilePath = join(cacheDir, 'issue_fields.json');
13
15
  this.ttlSeconds = ttlSeconds;
14
16
  }
15
17
  async isValid() {
@@ -33,6 +35,19 @@ export class MetadataCache {
33
35
  return null;
34
36
  }
35
37
  }
38
+ async loadIfValid() {
39
+ try {
40
+ const raw = await readFile(this.filePath, 'utf-8');
41
+ const file = JSON.parse(raw);
42
+ const ageSeconds = (Date.now() - file.timestamp) / 1000;
43
+ if (ageSeconds >= this.ttlSeconds)
44
+ return null;
45
+ return file.data;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
36
51
  async save(data) {
37
52
  await mkdir(this.cacheDir, { recursive: true });
38
53
  const file = {
@@ -49,4 +64,22 @@ export class MetadataCache {
49
64
  // Already gone — that is fine
50
65
  }
51
66
  }
67
+ async loadIssueFields() {
68
+ try {
69
+ const raw = await readFile(this.issueFieldsFilePath, 'utf-8');
70
+ const file = JSON.parse(raw);
71
+ const ageSeconds = (Date.now() - file.timestamp) / 1000;
72
+ if (ageSeconds >= this.ttlSeconds)
73
+ return null;
74
+ return file.fields;
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ async saveIssueFields(fields) {
81
+ await mkdir(this.cacheDir, { recursive: true });
82
+ const file = { timestamp: Date.now(), fields };
83
+ await writeFile(this.issueFieldsFilePath, JSON.stringify(file, null, 2), 'utf-8');
84
+ }
52
85
  }
package/dist/constants.js CHANGED
@@ -12,6 +12,9 @@ export const RELATIONSHIP_TYPES = {
12
12
  // ---------------------------------------------------------------------------
13
13
  // Status names (internal English names used in API calls)
14
14
  // ---------------------------------------------------------------------------
15
+ // MantisBT default status ID for "resolved". Issues with status.id strictly
16
+ // below this value are considered open (new/feedback/acknowledged/confirmed/assigned).
17
+ export const MANTIS_RESOLVED_STATUS_ID = 80;
15
18
  export const STATUS_NAMES = [
16
19
  'new',
17
20
  'feedback',
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { getVersionHint } from '../version-hint.js';
3
+ import { MANTIS_RESOLVED_STATUS_ID } from '../constants.js';
3
4
  function errorText(msg) {
4
5
  const vh = getVersionHint();
5
6
  vh?.triggerLatestVersionFetch();
@@ -39,7 +40,7 @@ export function registerIssueTools(server, client) {
39
40
  // ---------------------------------------------------------------------------
40
41
  server.registerTool('list_issues', {
41
42
  title: 'List Issues',
42
- description: 'List MantisBT issues with optional filtering. Returns a paginated list of 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
44
  inputSchema: z.object({
44
45
  project_id: z.number().int().positive().optional().describe('Filter by project ID'),
45
46
  page: z.number().int().positive().default(1).describe('Page number (default: 1)'),
@@ -49,13 +50,15 @@ export function registerIssueTools(server, client) {
49
50
  filter_id: z.number().int().positive().optional().describe('Use a saved MantisBT filter ID'),
50
51
  sort: z.string().optional().describe('Sort field (e.g. "last_updated", "id")'),
51
52
  direction: z.enum(['ASC', 'DESC']).optional().describe('Sort direction'),
53
+ 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"'),
54
+ 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.'),
52
55
  }),
53
56
  annotations: {
54
57
  readOnlyHint: true,
55
58
  destructiveHint: false,
56
59
  idempotentHint: true,
57
60
  },
58
- }, async ({ project_id, page, page_size, assigned_to, reporter_id, filter_id, sort, direction }) => {
61
+ }, async ({ project_id, page, page_size, assigned_to, reporter_id, filter_id, sort, direction, select, status }) => {
59
62
  try {
60
63
  const params = {
61
64
  page,
@@ -66,8 +69,19 @@ export function registerIssueTools(server, client) {
66
69
  filter_id,
67
70
  sort,
68
71
  direction,
72
+ select,
69
73
  };
70
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;
83
+ });
84
+ }
71
85
  return {
72
86
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
73
87
  };
@@ -1,5 +1,55 @@
1
1
  import { z } from 'zod';
2
2
  import { getVersionHint } from '../version-hint.js';
3
+ // Fields MantisBT strips from issue responses when the array is empty.
4
+ // They are always valid 'select' values even if absent in a sample issue.
5
+ const EMPTY_STRIPPED_FIELDS = [
6
+ 'attachments',
7
+ 'custom_fields',
8
+ 'history',
9
+ 'monitors',
10
+ 'notes',
11
+ 'relationships',
12
+ 'tags',
13
+ ];
14
+ // Fallback static list used when no issues are available to sample.
15
+ const STATIC_ISSUE_FIELDS = [
16
+ 'additional_information',
17
+ 'attachments',
18
+ 'build',
19
+ 'category',
20
+ 'created_at',
21
+ 'custom_fields',
22
+ 'description',
23
+ 'due_date',
24
+ 'eta',
25
+ 'fixed_in_version',
26
+ 'handler',
27
+ 'history',
28
+ 'id',
29
+ 'monitors',
30
+ 'notes',
31
+ 'os',
32
+ 'os_build',
33
+ 'platform',
34
+ 'priority',
35
+ 'profile',
36
+ 'project',
37
+ 'projection',
38
+ 'relationships',
39
+ 'reporter',
40
+ 'reproducibility',
41
+ 'resolution',
42
+ 'severity',
43
+ 'status',
44
+ 'steps_to_reproduce',
45
+ 'sticky',
46
+ 'summary',
47
+ 'tags',
48
+ 'target_version',
49
+ 'updated_at',
50
+ 'version',
51
+ 'view_state',
52
+ ];
3
53
  function errorText(msg) {
4
54
  const vh = getVersionHint();
5
55
  vh?.triggerLatestVersionFetch();
@@ -100,15 +150,60 @@ Use sync_metadata to force a refresh.`,
100
150
  },
101
151
  }, async () => {
102
152
  try {
103
- let data = null;
104
- if (await cache.isValid()) {
105
- data = await cache.load();
153
+ const data = await cache.loadIfValid() ?? await fetchAndCacheMetadata(client, cache);
154
+ return {
155
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
156
+ };
157
+ }
158
+ catch (error) {
159
+ const msg = error instanceof Error ? error.message : String(error);
160
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
161
+ }
162
+ });
163
+ // ---------------------------------------------------------------------------
164
+ // get_issue_fields
165
+ // ---------------------------------------------------------------------------
166
+ server.registerTool('get_issue_fields', {
167
+ title: 'Get Issue Fields',
168
+ description: `Return all field names that are valid for the "select" parameter of list_issues and get_issue.
169
+
170
+ Fields are discovered by fetching a sample issue from MantisBT (which reflects the server's active configuration — e.g. whether eta, projection, or profile fields are enabled) and merging the result with fields that MantisBT omits when empty (notes, attachments, relationships, etc.). The result is cached with the same TTL as the metadata cache.
171
+
172
+ Use this tool before constructing a "select" string to ensure you only request fields that exist on this server.`,
173
+ inputSchema: z.object({
174
+ project_id: z.number().int().positive().optional().describe('Optional project ID to scope the sample issue fetch'),
175
+ }),
176
+ annotations: {
177
+ readOnlyHint: true,
178
+ destructiveHint: false,
179
+ idempotentHint: true,
180
+ },
181
+ }, async ({ project_id }) => {
182
+ try {
183
+ const cached = await cache.loadIssueFields();
184
+ if (cached) {
185
+ return {
186
+ content: [{ type: 'text', text: JSON.stringify({ fields: cached, source: 'cache' }, null, 2) }],
187
+ };
106
188
  }
107
- if (!data) {
108
- data = await fetchAndCacheMetadata(client, cache);
189
+ const params = {
190
+ page: 1,
191
+ page_size: 1,
192
+ project_id,
193
+ };
194
+ const result = await client.get('issues', params);
195
+ const issues = result.issues ?? [];
196
+ let fields;
197
+ if (issues.length === 0) {
198
+ fields = STATIC_ISSUE_FIELDS;
109
199
  }
200
+ else {
201
+ const discovered = Object.keys(issues[0]);
202
+ fields = Array.from(new Set([...discovered, ...EMPTY_STRIPPED_FIELDS])).sort();
203
+ }
204
+ await cache.saveIssueFields(fields);
110
205
  return {
111
- content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
206
+ content: [{ type: 'text', text: JSON.stringify({ fields, source: issues.length > 0 ? 'live' : 'static' }, null, 2) }],
112
207
  };
113
208
  }
114
209
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dpesch/mantisbt-mcp-server",
3
- "version": "1.0.3",
3
+ "version": "1.1.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",
@@ -103,6 +103,17 @@ async function recordFixtures(): Promise<void> {
103
103
  console.error('Failed to fetch issues:', err instanceof Error ? err.message : String(err));
104
104
  }
105
105
 
106
+ // GET issues?page=1&page_size=1 — single issue sample for get_issue_fields field discovery
107
+ try {
108
+ const sampleResult = await client.get<{ issues: unknown[] }>('issues', {
109
+ page: 1,
110
+ page_size: 1,
111
+ });
112
+ saveFixture('get_issue_fields_sample.json', sampleResult);
113
+ } catch (err) {
114
+ console.error('Failed to fetch issues sample:', err instanceof Error ? err.message : String(err));
115
+ }
116
+
106
117
  // GET issues/{id}
107
118
  if (firstIssueId !== undefined) {
108
119
  try {
@@ -147,3 +147,118 @@ describe('MetadataCache – invalidate()', () => {
147
147
  await expect(cache.invalidate()).resolves.toBeUndefined();
148
148
  });
149
149
  });
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // loadIfValid()
153
+ // ---------------------------------------------------------------------------
154
+
155
+ describe('MetadataCache – loadIfValid()', () => {
156
+ it('returns null when file does not exist', async () => {
157
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
158
+
159
+ const cache = makeCache();
160
+ await expect(cache.loadIfValid()).resolves.toBeNull();
161
+ });
162
+
163
+ it('returns data when file is fresh (within TTL)', async () => {
164
+ const metadata = makeSampleMetadata();
165
+ const file = { timestamp: Date.now(), data: metadata };
166
+ vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
167
+
168
+ const cache = makeCache();
169
+ const result = await cache.loadIfValid();
170
+ expect(result).toEqual(metadata);
171
+ });
172
+
173
+ it('returns null when file has expired', async () => {
174
+ const expiredTimestamp = Date.now() - (TTL + 1) * 1000;
175
+ const metadata = makeSampleMetadata();
176
+ const file = { timestamp: expiredTimestamp, data: metadata };
177
+ vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
178
+
179
+ const cache = makeCache();
180
+ await expect(cache.loadIfValid()).resolves.toBeNull();
181
+ });
182
+
183
+ it('reads from metadata.json path', async () => {
184
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
185
+
186
+ const cache = makeCache();
187
+ await cache.loadIfValid();
188
+
189
+ expect(readFile).toHaveBeenCalledOnce();
190
+ const calledPath = vi.mocked(readFile).mock.calls[0][0] as string;
191
+ expect(calledPath).toContain('metadata.json');
192
+ expect(calledPath).not.toContain('issue_fields.json');
193
+ });
194
+ });
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // loadIssueFields()
198
+ // ---------------------------------------------------------------------------
199
+
200
+ describe('MetadataCache – loadIssueFields()', () => {
201
+ it('returns null when file does not exist', async () => {
202
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
203
+
204
+ const cache = makeCache();
205
+ await expect(cache.loadIssueFields()).resolves.toBeNull();
206
+ });
207
+
208
+ it('returns fields array when file is fresh', async () => {
209
+ const fields = ['id', 'summary', 'status'];
210
+ const file = { timestamp: Date.now(), fields };
211
+ vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
212
+
213
+ const cache = makeCache();
214
+ const result = await cache.loadIssueFields();
215
+ expect(result).toEqual(fields);
216
+ });
217
+
218
+ it('returns null when file has expired', async () => {
219
+ const expiredTimestamp = Date.now() - (TTL + 1) * 1000;
220
+ const file = { timestamp: expiredTimestamp, fields: ['id', 'summary'] };
221
+ vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
222
+
223
+ const cache = makeCache();
224
+ await expect(cache.loadIssueFields()).resolves.toBeNull();
225
+ });
226
+ });
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // saveIssueFields()
230
+ // ---------------------------------------------------------------------------
231
+
232
+ describe('MetadataCache – saveIssueFields()', () => {
233
+ it('calls mkdir with recursive:true and writes to issue_fields.json', async () => {
234
+ vi.mocked(mkdir).mockResolvedValue(undefined);
235
+ vi.mocked(writeFile).mockResolvedValue(undefined);
236
+
237
+ const cache = makeCache();
238
+ const fields = ['id', 'summary', 'status', 'priority'];
239
+ await cache.saveIssueFields(fields);
240
+
241
+ expect(mkdir).toHaveBeenCalledWith(CACHE_DIR, { recursive: true });
242
+ expect(writeFile).toHaveBeenCalledOnce();
243
+
244
+ const calledPath = vi.mocked(writeFile).mock.calls[0][0] as string;
245
+ expect(calledPath).toContain('issue_fields.json');
246
+ });
247
+
248
+ it('written JSON contains the fields array and a timestamp', async () => {
249
+ vi.mocked(mkdir).mockResolvedValue(undefined);
250
+ vi.mocked(writeFile).mockResolvedValue(undefined);
251
+
252
+ const cache = makeCache();
253
+ const fields = ['id', 'summary', 'status'];
254
+ const before = Date.now();
255
+ await cache.saveIssueFields(fields);
256
+ const after = Date.now();
257
+
258
+ const writtenContent = vi.mocked(writeFile).mock.calls[0][1] as string;
259
+ const parsed = JSON.parse(writtenContent) as { timestamp: number; fields: string[] };
260
+ expect(parsed.fields).toEqual(fields);
261
+ expect(parsed.timestamp).toBeGreaterThanOrEqual(before);
262
+ expect(parsed.timestamp).toBeLessThanOrEqual(after);
263
+ });
264
+ });
@@ -0,0 +1,110 @@
1
+ {
2
+ "issues": [
3
+ {
4
+ "id": 7857,
5
+ "summary": "Issue summary 1",
6
+ "description": "Issue description 1",
7
+ "project": {
8
+ "id": 54,
9
+ "name": "Project 2"
10
+ },
11
+ "category": {
12
+ "id": 394,
13
+ "name": "Hooks"
14
+ },
15
+ "reporter": {
16
+ "id": 51,
17
+ "name": "user_1",
18
+ "real_name": "User One",
19
+ "email": "user1@example.com"
20
+ },
21
+ "handler": {
22
+ "id": 51,
23
+ "name": "user_1",
24
+ "real_name": "User One",
25
+ "email": "user1@example.com"
26
+ },
27
+ "status": {
28
+ "id": 80,
29
+ "name": "resolved",
30
+ "label": "erledigt",
31
+ "color": "#e8e8e8"
32
+ },
33
+ "resolution": {
34
+ "id": 20,
35
+ "name": "fixed",
36
+ "label": "erledigt"
37
+ },
38
+ "view_state": {
39
+ "id": 10,
40
+ "name": "public",
41
+ "label": "öffentlich"
42
+ },
43
+ "priority": {
44
+ "id": 30,
45
+ "name": "normal",
46
+ "label": "normal"
47
+ },
48
+ "severity": {
49
+ "id": 0,
50
+ "name": "@0@",
51
+ "label": "@0@"
52
+ },
53
+ "reproducibility": {
54
+ "id": 70,
55
+ "name": "have not tried",
56
+ "label": "nicht getestet"
57
+ },
58
+ "sticky": false,
59
+ "created_at": "2026-03-15T22:11:31+01:00",
60
+ "updated_at": "2026-03-15T22:23:08+01:00",
61
+ "history": [
62
+ {
63
+ "created_at": "2026-03-15T22:11:31+01:00",
64
+ "user": {
65
+ "id": 51,
66
+ "name": "user_1",
67
+ "real_name": "User One",
68
+ "email": "user1@example.com"
69
+ },
70
+ "type": {
71
+ "id": 1,
72
+ "name": "issue-new"
73
+ },
74
+ "message": "Neuer Eintrag"
75
+ },
76
+ {
77
+ "created_at": "2026-03-15T22:23:08+01:00",
78
+ "user": {
79
+ "id": 51,
80
+ "name": "user_1",
81
+ "real_name": "User One",
82
+ "email": "user1@example.com"
83
+ },
84
+ "field": {
85
+ "name": "status",
86
+ "label": "Status"
87
+ },
88
+ "type": {
89
+ "id": 0,
90
+ "name": "field-updated"
91
+ },
92
+ "old_value": {
93
+ "id": 50,
94
+ "name": "assigned",
95
+ "label": "zugewiesen",
96
+ "color": "#afbed5"
97
+ },
98
+ "new_value": {
99
+ "id": 80,
100
+ "name": "resolved",
101
+ "label": "erledigt",
102
+ "color": "#e8e8e8"
103
+ },
104
+ "message": "Status",
105
+ "change": "zugewiesen => erledigt"
106
+ }
107
+ ]
108
+ }
109
+ ]
110
+ }
@@ -62,6 +62,40 @@
62
62
  "sticky": false,
63
63
  "created_at": "2026-03-13T09:58:23+01:00",
64
64
  "updated_at": "2026-03-13T11:25:19+01:00"
65
+ },
66
+ {
67
+ "id": 7900,
68
+ "summary": "Issue summary 4 (open)",
69
+ "description": "Issue description 4",
70
+ "project": { "id": 54, "name": "Project 2" },
71
+ "category": { "id": 350, "name": "Konfiguration" },
72
+ "reporter": { "id": 52, "name": "user_2" },
73
+ "handler": {
74
+ "id": 51,
75
+ "name": "user_1",
76
+ "real_name": "User One",
77
+ "email": "user1@example.com"
78
+ },
79
+ "status": { "id": 50, "name": "assigned", "label": "zugewiesen", "color": "#e8e8e8" },
80
+ "priority": { "id": 30, "name": "normal", "label": "normal" },
81
+ "severity": { "id": 210, "name": "Wartung", "label": "Wartung" },
82
+ "sticky": false,
83
+ "created_at": "2026-03-14T10:00:00+01:00",
84
+ "updated_at": "2026-03-14T10:00:00+01:00"
85
+ },
86
+ {
87
+ "id": 7901,
88
+ "summary": "Issue summary 5 (open, new)",
89
+ "description": "Issue description 5",
90
+ "project": { "id": 54, "name": "Project 2" },
91
+ "category": { "id": 350, "name": "Konfiguration" },
92
+ "reporter": { "id": 52, "name": "user_2" },
93
+ "status": { "id": 10, "name": "new", "label": "neu", "color": "#fcbdbd" },
94
+ "priority": { "id": 30, "name": "normal", "label": "normal" },
95
+ "severity": { "id": 210, "name": "Wartung", "label": "Wartung" },
96
+ "sticky": false,
97
+ "created_at": "2026-03-14T11:00:00+01:00",
98
+ "updated_at": "2026-03-14T11:00:00+01:00"
65
99
  }
66
100
  ]
67
101
  }
@@ -1,3 +1,13 @@
1
+ export function makeResponse(status: number, body: string): Response {
2
+ return {
3
+ ok: status >= 200 && status < 300,
4
+ status,
5
+ statusText: `Status ${status}`,
6
+ text: () => Promise.resolve(body),
7
+ headers: { get: (_key: string) => null },
8
+ } as unknown as Response;
9
+ }
10
+
1
11
  // Typ für das Result-Objekt das die Tools zurückgeben
2
12
  export interface ToolResult {
3
13
  content: Array<{ type: string; text: string }>;
@@ -4,7 +4,8 @@ import { join, dirname } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { MantisClient } from '../../src/client.js';
6
6
  import { registerIssueTools } from '../../src/tools/issues.js';
7
- import { MockMcpServer } from '../helpers/mock-server.js';
7
+ import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
8
+ import { MANTIS_RESOLVED_STATUS_ID } from '../../src/constants.js';
8
9
 
9
10
  const __filename = fileURLToPath(import.meta.url);
10
11
  const __dirname = dirname(__filename);
@@ -22,22 +23,13 @@ const getIssueFixture = existsSync(getIssueFixturePath)
22
23
  : { issues: [{ id: 42, summary: 'Test Issue' }] };
23
24
 
24
25
  const listIssuesFixture = existsSync(listIssuesFixturePath)
25
- ? (JSON.parse(readFileSync(listIssuesFixturePath, 'utf-8')) as { issues: Array<{ id: number; summary: string }>; total_count: number })
26
- : { issues: [{ id: 42, summary: 'Test Issue' }], total_count: 1 };
26
+ ? (JSON.parse(readFileSync(listIssuesFixturePath, 'utf-8')) as { issues: Array<{ id: number; summary: string; status: { id: number; name: string } }>; total_count: number })
27
+ : { issues: [{ id: 42, summary: 'Test Issue', status: { id: 80, name: 'resolved' } }], total_count: 1 };
27
28
 
28
- // ---------------------------------------------------------------------------
29
- // Helper: minimale Response nachbauen
30
- // ---------------------------------------------------------------------------
31
-
32
- function makeResponse(status: number, body: string): Response {
33
- return {
34
- ok: status >= 200 && status < 300,
35
- status,
36
- statusText: `Status ${status}`,
37
- text: () => Promise.resolve(body),
38
- headers: { get: (_key: string) => null },
39
- } as unknown as Response;
40
- }
29
+ const recordedListIssuesPath = join(fixturesDir, 'recorded', 'list_issues.json');
30
+ const recordedListIssuesFixture = existsSync(recordedListIssuesPath)
31
+ ? (JSON.parse(readFileSync(recordedListIssuesPath, 'utf-8')) as { issues: Array<{ id: number; summary: string; status: { id: number; name: string } }> })
32
+ : null;
41
33
 
42
34
  // ---------------------------------------------------------------------------
43
35
  // Setup
@@ -127,4 +119,92 @@ describe('list_issues', () => {
127
119
  const url = new URL(calledUrl);
128
120
  expect(url.searchParams.get('project_id')).toBe('7');
129
121
  });
122
+
123
+ it('passes select as query parameter', async () => {
124
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
125
+
126
+ await mockServer.callTool('list_issues', { select: 'id,summary,status', page: 1, page_size: 10 });
127
+
128
+ const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
129
+ const url = new URL(calledUrl);
130
+ expect(url.searchParams.get('select')).toBe('id,summary,status');
131
+ });
132
+
133
+ it('status "open" filters to issues with status.id < 80', async () => {
134
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
135
+
136
+ const result = await mockServer.callTool('list_issues', { status: 'open', page: 1, page_size: 50 });
137
+
138
+ expect(result.isError).toBeUndefined();
139
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ status: { id: number } }> };
140
+ expect(parsed.issues.length).toBeGreaterThan(0);
141
+ parsed.issues.forEach(issue => {
142
+ expect(issue.status.id).toBeLessThan(MANTIS_RESOLVED_STATUS_ID);
143
+ });
144
+ });
145
+
146
+ it('status "resolved" filters to resolved issues only', async () => {
147
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
148
+
149
+ const result = await mockServer.callTool('list_issues', { status: 'resolved', page: 1, page_size: 50 });
150
+
151
+ expect(result.isError).toBeUndefined();
152
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ status: { name: string } }> };
153
+ expect(parsed.issues.length).toBeGreaterThan(0);
154
+ parsed.issues.forEach(issue => {
155
+ expect(issue.status.name).toBe('resolved');
156
+ });
157
+ });
158
+
159
+ it('status "new" filters to new issues only', async () => {
160
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
161
+
162
+ const result = await mockServer.callTool('list_issues', { status: 'new', page: 1, page_size: 50 });
163
+
164
+ expect(result.isError).toBeUndefined();
165
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: Array<{ id: number; status: { name: string } }> };
166
+ expect(parsed.issues).toHaveLength(1);
167
+ expect(parsed.issues[0]!.id).toBe(7901);
168
+ expect(parsed.issues[0]!.status.name).toBe('new');
169
+ });
170
+
171
+ it('status filter is case-insensitive', async () => {
172
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
173
+ const resultUpper = await mockServer.callTool('list_issues', { status: 'OPEN', page: 1, page_size: 50 });
174
+
175
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(listIssuesFixture)));
176
+ const resultLower = await mockServer.callTool('list_issues', { status: 'open', page: 1, page_size: 50 });
177
+
178
+ const parsedUpper = JSON.parse(resultUpper.content[0]!.text) as { issues: unknown[] };
179
+ const parsedLower = JSON.parse(resultLower.content[0]!.text) as { issues: unknown[] };
180
+ expect(parsedUpper.issues.length).toBe(parsedLower.issues.length);
181
+ expect(parsedUpper.issues).toEqual(parsedLower.issues);
182
+ });
183
+ });
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // list_issues – recorded fixtures
187
+ // ---------------------------------------------------------------------------
188
+
189
+ describe('list_issues – recorded fixtures', () => {
190
+ it.skipIf(!recordedListIssuesFixture)('status "open" on all-resolved recorded fixture yields 0 results', async () => {
191
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(recordedListIssuesFixture!)));
192
+
193
+ const result = await mockServer.callTool('list_issues', { status: 'open', page: 1, page_size: 50 });
194
+
195
+ expect(result.isError).toBeUndefined();
196
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: unknown[] };
197
+ // Recorded fixture contains only resolved issues (status.id=80)
198
+ expect(parsed.issues).toHaveLength(0);
199
+ });
200
+
201
+ it.skipIf(!recordedListIssuesFixture)('status "resolved" on recorded fixture yields same count as total recorded issues', async () => {
202
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(recordedListIssuesFixture!)));
203
+
204
+ const result = await mockServer.callTool('list_issues', { status: 'resolved', page: 1, page_size: 50 });
205
+
206
+ expect(result.isError).toBeUndefined();
207
+ const parsed = JSON.parse(result.content[0]!.text) as { issues: unknown[] };
208
+ expect(parsed.issues).toHaveLength(recordedListIssuesFixture!.issues.length);
209
+ });
130
210
  });
@@ -0,0 +1,261 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { readFileSync, existsSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ // vi.mock must be at module top level — vitest hoists it automatically
7
+ vi.mock('node:fs/promises');
8
+
9
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
10
+ import { MantisClient } from '../../src/client.js';
11
+ import { MetadataCache, type CachedMetadata } from '../../src/cache.js';
12
+ import { registerMetadataTools } from '../../src/tools/metadata.js';
13
+ import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+ const recordedFixturesDir = join(__dirname, '..', 'fixtures', 'recorded');
18
+
19
+ // Recorded fixture: single issue returned by issues?page=1&page_size=1
20
+ const sampleFixturePath = join(recordedFixturesDir, 'get_issue_fields_sample.json');
21
+ const recordedSampleFixture = existsSync(sampleFixturePath)
22
+ ? (JSON.parse(readFileSync(sampleFixturePath, 'utf-8')) as { issues: Array<Record<string, unknown>> })
23
+ : null;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const CACHE_DIR = '/tmp/test-cache-metadata';
30
+ const TTL = 3600;
31
+
32
+ function makeServer(): MockMcpServer {
33
+ return new MockMcpServer();
34
+ }
35
+
36
+ function makeClient(): MantisClient {
37
+ return new MantisClient('https://mantis.example.com', 'test-token');
38
+ }
39
+
40
+ function makeCache(): MetadataCache {
41
+ return new MetadataCache(CACHE_DIR, TTL);
42
+ }
43
+
44
+ function makeSampleMetadata(): CachedMetadata {
45
+ return {
46
+ timestamp: Date.now(),
47
+ projects: [{ id: 1, name: 'Test Project' }],
48
+ byProject: {},
49
+ };
50
+ }
51
+
52
+ function makeValidMetadataCacheFile(data: CachedMetadata): string {
53
+ return JSON.stringify({ timestamp: Date.now(), data });
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Setup
58
+ // ---------------------------------------------------------------------------
59
+
60
+ let mockServer: MockMcpServer;
61
+ let client: MantisClient;
62
+ let cache: MetadataCache;
63
+
64
+ beforeEach(() => {
65
+ vi.resetAllMocks();
66
+ mockServer = makeServer();
67
+ client = makeClient();
68
+ cache = makeCache();
69
+ registerMetadataTools(mockServer as never, client, cache);
70
+ vi.stubGlobal('fetch', vi.fn());
71
+ });
72
+
73
+ afterEach(() => {
74
+ vi.unstubAllGlobals();
75
+ });
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // get_metadata
79
+ // ---------------------------------------------------------------------------
80
+
81
+ describe('get_metadata', () => {
82
+ it('is registered', () => {
83
+ expect(mockServer.hasToolRegistered('get_metadata')).toBe(true);
84
+ });
85
+
86
+ it('loads from cache when valid (single file read — no re-fetch)', async () => {
87
+ const metadata = makeSampleMetadata();
88
+ vi.mocked(readFile).mockResolvedValue(makeValidMetadataCacheFile(metadata) as any);
89
+
90
+ const result = await mockServer.callTool('get_metadata', {});
91
+
92
+ expect(result.isError).toBeUndefined();
93
+ // fetch must NOT have been called — data came from cache
94
+ expect(fetch).not.toHaveBeenCalled();
95
+ const parsed = JSON.parse(result.content[0]!.text) as CachedMetadata;
96
+ expect(parsed.projects).toEqual(metadata.projects);
97
+ });
98
+
99
+ it('fetches and caches when cache is missing', async () => {
100
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
101
+ vi.mocked(mkdir).mockResolvedValue(undefined);
102
+ vi.mocked(writeFile).mockResolvedValue(undefined);
103
+
104
+ const projectsResponse = { projects: [{ id: 1, name: 'Test Project' }] };
105
+ const emptyResponse = { users: [], versions: [], categories: [] };
106
+ vi.mocked(fetch)
107
+ .mockResolvedValueOnce(makeResponse(200, JSON.stringify(projectsResponse)))
108
+ .mockResolvedValue(makeResponse(200, JSON.stringify(emptyResponse)));
109
+
110
+ const result = await mockServer.callTool('get_metadata', {});
111
+
112
+ expect(result.isError).toBeUndefined();
113
+ expect(fetch).toHaveBeenCalled();
114
+ expect(writeFile).toHaveBeenCalled();
115
+ const writtenPath = vi.mocked(writeFile).mock.calls[0]![0] as string;
116
+ expect(writtenPath).toContain('metadata.json');
117
+ });
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // get_issue_fields
122
+ // ---------------------------------------------------------------------------
123
+
124
+ describe('get_issue_fields', () => {
125
+ it('is registered', () => {
126
+ expect(mockServer.hasToolRegistered('get_issue_fields')).toBe(true);
127
+ });
128
+
129
+ it('returns cached fields when cache is valid', async () => {
130
+ const cachedFields = ['id', 'summary', 'status', 'priority', 'attachments'];
131
+ const cacheFile = JSON.stringify({ timestamp: Date.now(), fields: cachedFields });
132
+
133
+ // loadIssueFields reads from issue_fields.json
134
+ vi.mocked(readFile).mockImplementation(async (path) => {
135
+ if (String(path).includes('issue_fields.json')) {
136
+ return cacheFile as any;
137
+ }
138
+ throw new Error('ENOENT');
139
+ });
140
+
141
+ const result = await mockServer.callTool('get_issue_fields', {});
142
+
143
+ expect(result.isError).toBeUndefined();
144
+ // fetch must NOT have been called — data came from cache
145
+ expect(fetch).not.toHaveBeenCalled();
146
+ const parsed = JSON.parse(result.content[0]!.text) as { fields: string[]; source: string };
147
+ expect(parsed.source).toBe('cache');
148
+ expect(parsed.fields).toEqual(cachedFields);
149
+ });
150
+
151
+ it('fetches sample issue and discovers fields', async () => {
152
+ // Cache miss
153
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
154
+ vi.mocked(mkdir).mockResolvedValue(undefined);
155
+ vi.mocked(writeFile).mockResolvedValue(undefined);
156
+
157
+ const sampleIssue = {
158
+ id: 42,
159
+ summary: 'Sample',
160
+ status: { id: 10, name: 'new' },
161
+ priority: { id: 30, name: 'normal' },
162
+ reporter: { id: 1, name: 'user' },
163
+ };
164
+ const issuesResponse = { issues: [sampleIssue] };
165
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(issuesResponse)));
166
+
167
+ const result = await mockServer.callTool('get_issue_fields', {});
168
+
169
+ expect(result.isError).toBeUndefined();
170
+ const parsed = JSON.parse(result.content[0]!.text) as { fields: string[]; source: string };
171
+ expect(parsed.source).toBe('live');
172
+ // Fields from sample issue
173
+ expect(parsed.fields).toContain('id');
174
+ expect(parsed.fields).toContain('summary');
175
+ expect(parsed.fields).toContain('status');
176
+ // Fields from EMPTY_STRIPPED_FIELDS that are always merged in
177
+ expect(parsed.fields).toContain('attachments');
178
+ expect(parsed.fields).toContain('notes');
179
+ expect(parsed.fields).toContain('relationships');
180
+ });
181
+
182
+ it('falls back to static list when no issues available', async () => {
183
+ // Cache miss
184
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
185
+ vi.mocked(mkdir).mockResolvedValue(undefined);
186
+ vi.mocked(writeFile).mockResolvedValue(undefined);
187
+
188
+ const emptyIssuesResponse = { issues: [] };
189
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(emptyIssuesResponse)));
190
+
191
+ const result = await mockServer.callTool('get_issue_fields', {});
192
+
193
+ expect(result.isError).toBeUndefined();
194
+ const parsed = JSON.parse(result.content[0]!.text) as { fields: string[]; source: string };
195
+ expect(parsed.source).toBe('static');
196
+ // Static list must contain common fields
197
+ expect(parsed.fields).toContain('id');
198
+ expect(parsed.fields).toContain('summary');
199
+ expect(parsed.fields).toContain('status');
200
+ expect(parsed.fields).toContain('attachments');
201
+ expect(parsed.fields).toContain('notes');
202
+ });
203
+
204
+ it('caches discovered fields after fetching', async () => {
205
+ // Cache miss
206
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
207
+ vi.mocked(mkdir).mockResolvedValue(undefined);
208
+ vi.mocked(writeFile).mockResolvedValue(undefined);
209
+
210
+ const sampleIssue = { id: 1, summary: 'Test', status: { id: 10, name: 'new' } };
211
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ issues: [sampleIssue] })));
212
+
213
+ await mockServer.callTool('get_issue_fields', {});
214
+
215
+ // writeFile must have been called with issue_fields.json path
216
+ expect(writeFile).toHaveBeenCalled();
217
+ const writtenPath = vi.mocked(writeFile).mock.calls.find(
218
+ call => String(call[0]).includes('issue_fields.json')
219
+ );
220
+ expect(writtenPath).toBeDefined();
221
+
222
+ // Written content must be valid JSON with a fields array
223
+ const writtenContent = writtenPath![1] as string;
224
+ const parsed = JSON.parse(writtenContent) as { timestamp: number; fields: string[] };
225
+ expect(Array.isArray(parsed.fields)).toBe(true);
226
+ expect(parsed.fields.length).toBeGreaterThan(0);
227
+ expect(typeof parsed.timestamp).toBe('number');
228
+ });
229
+ });
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // get_issue_fields – recorded fixtures
233
+ // ---------------------------------------------------------------------------
234
+
235
+ describe('get_issue_fields – recorded fixtures', () => {
236
+ it.skipIf(!recordedSampleFixture)('discovers fields from recorded sample issue', async () => {
237
+ // Cache miss — force live discovery
238
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
239
+ vi.mocked(mkdir).mockResolvedValue(undefined);
240
+ vi.mocked(writeFile).mockResolvedValue(undefined);
241
+
242
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(recordedSampleFixture!)));
243
+
244
+ const result = await mockServer.callTool('get_issue_fields', {});
245
+
246
+ expect(result.isError).toBeUndefined();
247
+ const parsed = JSON.parse(result.content[0]!.text) as { fields: string[]; source: string };
248
+ expect(parsed.source).toBe('live');
249
+
250
+ // Every top-level key of the recorded sample issue must be in the discovered fields
251
+ const sampleKeys = Object.keys(recordedSampleFixture!.issues[0]!);
252
+ for (const key of sampleKeys) {
253
+ expect(parsed.fields).toContain(key);
254
+ }
255
+
256
+ // EMPTY_STRIPPED_FIELDS must always be present even if absent from the sample
257
+ expect(parsed.fields).toContain('attachments');
258
+ expect(parsed.fields).toContain('notes');
259
+ expect(parsed.fields).toContain('relationships');
260
+ });
261
+ });
@@ -4,7 +4,7 @@ import { join, dirname } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { MantisClient } from '../../src/client.js';
6
6
  import { registerProjectTools } from '../../src/tools/projects.js';
7
- import { MockMcpServer } from '../helpers/mock-server.js';
7
+ import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
8
8
 
9
9
  const __filename = fileURLToPath(import.meta.url);
10
10
  const __dirname = dirname(__filename);
@@ -22,20 +22,6 @@ const listProjectsFixture = existsSync(listProjectsFixturePath)
22
22
 
23
23
  const firstProjectId = listProjectsFixture.projects[0]?.id ?? 1;
24
24
 
25
- // ---------------------------------------------------------------------------
26
- // Helper
27
- // ---------------------------------------------------------------------------
28
-
29
- function makeResponse(status: number, body: string): Response {
30
- return {
31
- ok: status >= 200 && status < 300,
32
- status,
33
- statusText: `Status ${status}`,
34
- text: () => Promise.resolve(body),
35
- headers: { get: (_key: string) => null },
36
- } as unknown as Response;
37
- }
38
-
39
25
  // ---------------------------------------------------------------------------
40
26
  // Setup
41
27
  // ---------------------------------------------------------------------------
@@ -4,7 +4,7 @@ import { join, dirname } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { MantisClient } from '../../src/client.js';
6
6
  import { registerUserTools } from '../../src/tools/users.js';
7
- import { MockMcpServer } from '../helpers/mock-server.js';
7
+ import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
8
8
 
9
9
  const __filename = fileURLToPath(import.meta.url);
10
10
  const __dirname = dirname(__filename);
@@ -20,20 +20,6 @@ const getCurrentUserFixture = existsSync(getCurrentUserFixturePath)
20
20
  ? (JSON.parse(readFileSync(getCurrentUserFixturePath, 'utf-8')) as { id: number; name: string; real_name?: string; email?: string })
21
21
  : { id: 5, name: 'testuser', real_name: 'Test User', email: 'test@example.com' };
22
22
 
23
- // ---------------------------------------------------------------------------
24
- // Helper
25
- // ---------------------------------------------------------------------------
26
-
27
- function makeResponse(status: number, body: string): Response {
28
- return {
29
- ok: status >= 200 && status < 300,
30
- status,
31
- statusText: `Status ${status}`,
32
- text: () => Promise.resolve(body),
33
- headers: { get: (_key: string) => null },
34
- } as unknown as Response;
35
- }
36
-
37
23
  // ---------------------------------------------------------------------------
38
24
  // Setup
39
25
  // ---------------------------------------------------------------------------