@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.
- package/.gitea/PULL_REQUEST_TEMPLATE.md +17 -0
- package/CHANGELOG.md +9 -0
- package/README.de.md +2 -2
- package/README.md +2 -2
- package/dist/cache.js +33 -0
- package/dist/constants.js +3 -0
- package/dist/tools/issues.js +16 -2
- package/dist/tools/metadata.js +101 -6
- package/package.json +1 -1
- package/scripts/record-fixtures.ts +11 -0
- package/tests/cache.test.ts +115 -0
- package/tests/fixtures/get_issue_fields_sample.json +110 -0
- package/tests/fixtures/list_issues.json +34 -0
- package/tests/helpers/mock-server.ts +10 -0
- package/tests/tools/issues.test.ts +96 -16
- package/tests/tools/metadata.test.ts +261 -0
- package/tests/tools/projects.test.ts +1 -15
- package/tests/tools/users.test.ts +1 -15
|
@@ -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
|
-
[](https://codeberg.org/dpesch/mantisbt-mcp-server/actions)
|
|
4
3
|
[](https://www.npmjs.com/package/@dpesch/mantisbt-mcp-server)
|
|
5
4
|
[](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
|
-
[](https://codeberg.org/dpesch/mantisbt-mcp-server/actions)
|
|
4
3
|
[](https://www.npmjs.com/package/@dpesch/mantisbt-mcp-server)
|
|
5
4
|
[](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',
|
package/dist/tools/issues.js
CHANGED
|
@@ -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
|
};
|
package/dist/tools/metadata.js
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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(
|
|
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
|
@@ -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 {
|
package/tests/cache.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|