@dpesch/mantisbt-mcp-server 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.de.md +6 -3
- package/README.md +6 -3
- package/dist/cache.js +3 -0
- package/dist/constants.js +5 -0
- package/dist/index.js +1 -1
- package/dist/resources/index.js +54 -6
- package/dist/tools/metadata.js +64 -5
- package/dist/tools/projects.js +51 -3
- package/docs/cookbook.de.md +158 -1
- package/docs/cookbook.md +158 -1
- package/docs/examples.de.md +6 -0
- package/docs/examples.md +6 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/tests/helpers/mock-server.ts +43 -14
- package/tests/resources/resources.test.ts +117 -0
- package/tests/tools/metadata.test.ts +128 -2
- package/tests/tools/projects.test.ts +153 -1
package/docs/cookbook.md
CHANGED
|
@@ -46,6 +46,10 @@ Tool-oriented recipes for the MantisBT MCP server — each recipe shows exactly
|
|
|
46
46
|
- [Search with field enrichment](#search-with-field-enrichment)
|
|
47
47
|
- [Projects & Categories](#projects--categories)
|
|
48
48
|
- [List project categories](#list-project-categories)
|
|
49
|
+
- [Find a project member](#find-a-project-member)
|
|
50
|
+
- [Metadata](#metadata)
|
|
51
|
+
- [Get a metadata summary](#get-a-metadata-summary)
|
|
52
|
+
- [Get the full metadata cache](#get-the-full-metadata-cache)
|
|
49
53
|
- [Version & Diagnostics](#version--diagnostics)
|
|
50
54
|
- [Get MCP server version](#get-mcp-server-version)
|
|
51
55
|
- [Get MantisBT version](#get-mantis-version)
|
|
@@ -53,6 +57,7 @@ Tool-oriented recipes for the MantisBT MCP server — each recipe shows exactly
|
|
|
53
57
|
- [Resources](#resources)
|
|
54
58
|
- [Read the current user profile](#read-the-current-user-profile)
|
|
55
59
|
- [Read all projects](#read-all-projects)
|
|
60
|
+
- [Read a single project with all details](#read-a-single-project-with-all-details)
|
|
56
61
|
- [Read issue enum values](#read-issue-enum-values)
|
|
57
62
|
- [Prompts](#prompts)
|
|
58
63
|
- [Create a bug report](#create-a-bug-report)
|
|
@@ -1079,7 +1084,7 @@ Read `tags[].id` from the response.
|
|
|
1079
1084
|
"Tag #14 successfully removed from issue #1042."
|
|
1080
1085
|
```
|
|
1081
1086
|
|
|
1082
|
-
> **Note:** `detach_tag` requires a numeric ID, not the tag name. There is no lookup by name — always retrieve the ID first via `get_issue` or `
|
|
1087
|
+
> **Note:** `detach_tag` requires a numeric ID, not the tag name. There is no lookup by name — always retrieve the ID first via `get_issue` or `list_tags`.
|
|
1083
1088
|
|
|
1084
1089
|
---
|
|
1085
1090
|
|
|
@@ -1338,6 +1343,123 @@ Returns all categories available for a MantisBT project. Use the returned names
|
|
|
1338
1343
|
|
|
1339
1344
|
---
|
|
1340
1345
|
|
|
1346
|
+
### Find a project member
|
|
1347
|
+
|
|
1348
|
+
Searches project members by name, real name, or email. The search is case-insensitive and matches any substring. Results are served from the metadata cache when available; falls back to a live API call on a cold cache.
|
|
1349
|
+
|
|
1350
|
+
**Tool:** `find_project_member`
|
|
1351
|
+
|
|
1352
|
+
**Parameters:**
|
|
1353
|
+
- `project_id` — numeric project ID
|
|
1354
|
+
- `query` — _(optional)_ substring to match against `name`, `real_name`, or `email`
|
|
1355
|
+
- `limit` — _(optional)_ maximum number of results, default 10, max 100
|
|
1356
|
+
|
|
1357
|
+
**Request:**
|
|
1358
|
+
|
|
1359
|
+
```json
|
|
1360
|
+
{
|
|
1361
|
+
"project_id": 3,
|
|
1362
|
+
"query": "smith",
|
|
1363
|
+
"limit": 5
|
|
1364
|
+
}
|
|
1365
|
+
```
|
|
1366
|
+
|
|
1367
|
+
**Response:**
|
|
1368
|
+
|
|
1369
|
+
```json
|
|
1370
|
+
[
|
|
1371
|
+
{ "id": 4, "name": "jsmith", "real_name": "John Smith", "email": "jsmith@example.com", "access_level": { "id": 55, "name": "developer" } },
|
|
1372
|
+
{ "id": 11, "name": "asmith", "real_name": "Alice Smith", "email": "asmith@example.com", "access_level": { "id": 40, "name": "reporter" } }
|
|
1373
|
+
]
|
|
1374
|
+
```
|
|
1375
|
+
|
|
1376
|
+
> **Tip:** Omit `query` to list all members of the project (up to `limit`).
|
|
1377
|
+
|
|
1378
|
+
---
|
|
1379
|
+
|
|
1380
|
+
## Metadata
|
|
1381
|
+
|
|
1382
|
+
### Get a metadata summary
|
|
1383
|
+
|
|
1384
|
+
Returns a compact overview of all cached metadata: total project and tag counts, and per-project counts for users, versions, and categories. Use this to get a quick overview without transferring large arrays.
|
|
1385
|
+
|
|
1386
|
+
**Tool:** `get_metadata`
|
|
1387
|
+
|
|
1388
|
+
**Parameters:** _(none)_
|
|
1389
|
+
|
|
1390
|
+
**Request:**
|
|
1391
|
+
|
|
1392
|
+
```json
|
|
1393
|
+
{}
|
|
1394
|
+
```
|
|
1395
|
+
|
|
1396
|
+
**Response:**
|
|
1397
|
+
|
|
1398
|
+
```json
|
|
1399
|
+
{
|
|
1400
|
+
"projects": 24,
|
|
1401
|
+
"tags": 15,
|
|
1402
|
+
"byProject": {
|
|
1403
|
+
"3": { "name": "Webshop", "users": 8, "versions": 12, "categories": 4 },
|
|
1404
|
+
"5": { "name": "Backend API", "users": 5, "versions": 7, "categories": 3 }
|
|
1405
|
+
},
|
|
1406
|
+
"cached_at": "2026-03-27T09:00:00.000Z",
|
|
1407
|
+
"ttl_seconds": 82800
|
|
1408
|
+
}
|
|
1409
|
+
```
|
|
1410
|
+
|
|
1411
|
+
> **Note:** For full lists use `list_projects`, `get_project_users`, `get_project_versions`, `get_project_categories`, `list_tags`, or `get_metadata_full`.
|
|
1412
|
+
|
|
1413
|
+
---
|
|
1414
|
+
|
|
1415
|
+
### Get the full metadata cache
|
|
1416
|
+
|
|
1417
|
+
Returns the complete raw metadata cache as minified JSON. Contains all projects with their full field set, plus users, versions, and categories per project, and all tags. Use this when you need the complete data in a single call.
|
|
1418
|
+
|
|
1419
|
+
**Tool:** `get_metadata_full`
|
|
1420
|
+
|
|
1421
|
+
**Parameters:** _(none)_
|
|
1422
|
+
|
|
1423
|
+
**Request:**
|
|
1424
|
+
|
|
1425
|
+
```json
|
|
1426
|
+
{}
|
|
1427
|
+
```
|
|
1428
|
+
|
|
1429
|
+
**Response:**
|
|
1430
|
+
|
|
1431
|
+
```json
|
|
1432
|
+
{
|
|
1433
|
+
"projects": [
|
|
1434
|
+
{
|
|
1435
|
+
"id": 3,
|
|
1436
|
+
"name": "Webshop",
|
|
1437
|
+
"status": { "id": 10, "name": "development" },
|
|
1438
|
+
"enabled": true,
|
|
1439
|
+
"users": [
|
|
1440
|
+
{ "id": 4, "name": "jsmith", "real_name": "John Smith", "email": "jsmith@example.com", "access_level": { "id": 55, "name": "developer" } }
|
|
1441
|
+
],
|
|
1442
|
+
"versions": [
|
|
1443
|
+
{ "id": 21, "name": "2.4.0", "released": false, "obsolete": false }
|
|
1444
|
+
],
|
|
1445
|
+
"categories": [
|
|
1446
|
+
{ "id": 1, "name": "General" },
|
|
1447
|
+
{ "id": 2, "name": "UI" }
|
|
1448
|
+
]
|
|
1449
|
+
}
|
|
1450
|
+
],
|
|
1451
|
+
"tags": [
|
|
1452
|
+
{ "id": 1, "name": "regression" },
|
|
1453
|
+
{ "id": 2, "name": "hotfix" }
|
|
1454
|
+
],
|
|
1455
|
+
"cached_at": "2026-03-27T09:00:00.000Z"
|
|
1456
|
+
}
|
|
1457
|
+
```
|
|
1458
|
+
|
|
1459
|
+
> **Tip:** `get_metadata` provides the same data as a compact summary (counts only). Use `get_metadata_full` when you need the actual arrays.
|
|
1460
|
+
|
|
1461
|
+
---
|
|
1462
|
+
|
|
1341
1463
|
## Version & Diagnostics
|
|
1342
1464
|
|
|
1343
1465
|
### Get MCP server version
|
|
@@ -1476,6 +1598,41 @@ Returns all MantisBT projects accessible with the configured API key.
|
|
|
1476
1598
|
|
|
1477
1599
|
---
|
|
1478
1600
|
|
|
1601
|
+
### Read a single project with all details
|
|
1602
|
+
|
|
1603
|
+
Returns a combined view of a single project: its fields plus all members, versions, and categories in one call. Served from the metadata cache when available; falls back to three parallel API calls on a cold cache. Clients that support Resource list can enumerate all available project URIs.
|
|
1604
|
+
|
|
1605
|
+
**Resource URI:** `mantis://projects/{id}` (replace `{id}` with the numeric project ID)
|
|
1606
|
+
|
|
1607
|
+
**Fetch behaviour:** Cache-first (MetadataCache, default TTL 24 h). Falls back to live API calls when the cache is empty.
|
|
1608
|
+
|
|
1609
|
+
**Example URI:** `mantis://projects/42`
|
|
1610
|
+
|
|
1611
|
+
**Response:**
|
|
1612
|
+
|
|
1613
|
+
```json
|
|
1614
|
+
{
|
|
1615
|
+
"id": 3,
|
|
1616
|
+
"name": "Webshop",
|
|
1617
|
+
"status": { "id": 10, "name": "development" },
|
|
1618
|
+
"enabled": true,
|
|
1619
|
+
"users": [
|
|
1620
|
+
{ "id": 4, "name": "jsmith", "real_name": "John Smith", "email": "jsmith@example.com", "access_level": { "id": 55, "name": "developer" } }
|
|
1621
|
+
],
|
|
1622
|
+
"versions": [
|
|
1623
|
+
{ "id": 21, "name": "2.4.0", "released": false, "obsolete": false }
|
|
1624
|
+
],
|
|
1625
|
+
"categories": [
|
|
1626
|
+
{ "id": 1, "name": "General" },
|
|
1627
|
+
{ "id": 2, "name": "UI" }
|
|
1628
|
+
]
|
|
1629
|
+
}
|
|
1630
|
+
```
|
|
1631
|
+
|
|
1632
|
+
> **Tip:** Use `mantis://projects` to get a compact list of all projects, then fetch individual project details via `mantis://projects/{id}`.
|
|
1633
|
+
|
|
1634
|
+
---
|
|
1635
|
+
|
|
1479
1636
|
### Read issue enum values
|
|
1480
1637
|
|
|
1481
1638
|
Returns valid ID/name pairs for all issue enum fields (severity, priority, status, resolution, reproducibility). Use this to look up canonical English names before calling `create_issue` or `update_issue`.
|
package/docs/examples.de.md
CHANGED
|
@@ -93,6 +93,10 @@ Praktische Beispiele für die Interaktion mit MantisBT über Claude, sobald der
|
|
|
93
93
|
|
|
94
94
|
> »Wer sind die Mitglieder des API-Projekts?«
|
|
95
95
|
|
|
96
|
+
> »Gibt es im Projekt 3 einen Benutzer namens 'schmidt'?«
|
|
97
|
+
|
|
98
|
+
> »Finde alle Mitglieder des Webshop-Projekts, deren E-Mail '@example.com' enthält.«
|
|
99
|
+
|
|
96
100
|
---
|
|
97
101
|
|
|
98
102
|
### Triage und Auswertung
|
|
@@ -187,6 +191,8 @@ MCP-Ressourcen sind URI-adressierbare, schreibgeschützte Daten, die Clients dir
|
|
|
187
191
|
|
|
188
192
|
> »Rufe `mantis://projects` ab, um die Liste der verfügbaren Projekte zu erhalten.«
|
|
189
193
|
|
|
194
|
+
> »Lese `mantis://projects/3`, um alle Details des Webshop-Projekts zu sehen: Mitglieder, Versionen und Kategorien in einem Aufruf.«
|
|
195
|
+
|
|
190
196
|
> »Lade `mantis://enums`, um die gültigen Severity- und Priority-Werte einzusehen, bevor ein Issue erstellt wird.«
|
|
191
197
|
|
|
192
198
|
### Alternative Tools für Clients ohne Ressourcen-Unterstützung
|
package/docs/examples.md
CHANGED
|
@@ -93,6 +93,10 @@ Practical examples of how to interact with MantisBT through Claude once the MCP
|
|
|
93
93
|
|
|
94
94
|
> "Who are the members of the API project?"
|
|
95
95
|
|
|
96
|
+
> "Is there a user named 'smith' in project 3?"
|
|
97
|
+
|
|
98
|
+
> "Find all members of the Webshop project whose email contains '@example.com'."
|
|
99
|
+
|
|
96
100
|
---
|
|
97
101
|
|
|
98
102
|
### Triage and reporting
|
|
@@ -187,6 +191,8 @@ MCP Resources are URI-addressable, read-only data that clients can fetch directl
|
|
|
187
191
|
|
|
188
192
|
> "Fetch `mantis://projects` to get the list of available projects."
|
|
189
193
|
|
|
194
|
+
> "Read `mantis://projects/3` to see all details of the Webshop project: members, versions, and categories in one call."
|
|
195
|
+
|
|
190
196
|
> "Load `mantis://enums` to see valid severity and priority values before creating an issue."
|
|
191
197
|
|
|
192
198
|
### Equivalent tool fallbacks
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
"name": "io.github.dpesch/mantisbt-mcp-server",
|
|
4
4
|
"title": "MantisBT MCP Server",
|
|
5
5
|
"description": "MantisBT MCP server – manage issues, notes, files, tags, and relationships. With semantic search.",
|
|
6
|
-
"version": "1.
|
|
6
|
+
"version": "1.8.0",
|
|
7
7
|
"packages": [
|
|
8
8
|
{
|
|
9
9
|
"registryType": "npm",
|
|
10
10
|
"identifier": "@dpesch/mantisbt-mcp-server",
|
|
11
|
-
"version": "1.
|
|
11
|
+
"version": "1.8.0",
|
|
12
12
|
"runtimeHint": "npx",
|
|
13
13
|
"transport": {
|
|
14
14
|
"type": "stdio"
|
|
@@ -39,7 +39,21 @@ export interface ResourceResult {
|
|
|
39
39
|
|
|
40
40
|
type PromptHandler = (args: Record<string, unknown>) => PromptResult;
|
|
41
41
|
|
|
42
|
-
type ResourceHandler = (uri: URL) => Promise<ResourceResult>;
|
|
42
|
+
type ResourceHandler = (uri: URL, variables?: Record<string, string>) => Promise<ResourceResult>;
|
|
43
|
+
|
|
44
|
+
interface ResourceEntry {
|
|
45
|
+
handler: ResourceHandler;
|
|
46
|
+
/** URI template pattern, e.g. 'mantis://projects/{id}'. Present for template resources only. */
|
|
47
|
+
template?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function matchTemplate(template: string, uri: string): Record<string, string> | null {
|
|
51
|
+
const names = [...template.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]!);
|
|
52
|
+
const pattern = template.replace(/\{[^}]+\}/g, '([^/]+)');
|
|
53
|
+
const match = uri.match(new RegExp(`^${pattern}$`));
|
|
54
|
+
if (!match) return null;
|
|
55
|
+
return Object.fromEntries(names.map((name, i) => [name, match[i + 1]!]));
|
|
56
|
+
}
|
|
43
57
|
|
|
44
58
|
interface PromptDefinition {
|
|
45
59
|
argsSchema?: Record<string, z.ZodTypeAny>;
|
|
@@ -50,7 +64,7 @@ export class MockMcpServer {
|
|
|
50
64
|
private readonly handlers = new Map<string, ToolHandler>();
|
|
51
65
|
private readonly schemas = new Map<string, z.ZodTypeAny>();
|
|
52
66
|
private readonly promptHandlers = new Map<string, PromptHandler>();
|
|
53
|
-
private readonly
|
|
67
|
+
private readonly resourceEntries = new Map<string, ResourceEntry>();
|
|
54
68
|
|
|
55
69
|
// Nachahmt McpServer.registerTool – fängt Handler und Schema ein
|
|
56
70
|
registerTool(name: string, definition: ToolDefinition, handler: ToolHandler): void {
|
|
@@ -78,16 +92,17 @@ export class MockMcpServer {
|
|
|
78
92
|
const handler = this.handlers.get(name);
|
|
79
93
|
if (!handler) throw new Error(`Tool not registered: ${name}`);
|
|
80
94
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
95
|
+
const schema = this.schemas.get(name);
|
|
96
|
+
if (schema) {
|
|
97
|
+
const parsed = schema.safeParse(args);
|
|
98
|
+
if (!parsed.success) {
|
|
99
|
+
if (options.validate) {
|
|
86
100
|
return {
|
|
87
101
|
content: [{ type: 'text', text: `Validation error: ${parsed.error.message}` }],
|
|
88
102
|
isError: true,
|
|
89
103
|
};
|
|
90
104
|
}
|
|
105
|
+
} else {
|
|
91
106
|
return handler(parsed.data as Record<string, unknown>);
|
|
92
107
|
}
|
|
93
108
|
}
|
|
@@ -117,21 +132,35 @@ export class MockMcpServer {
|
|
|
117
132
|
return [...this.promptHandlers.keys()];
|
|
118
133
|
}
|
|
119
134
|
|
|
120
|
-
|
|
121
|
-
|
|
135
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
136
|
+
registerResource(name: string, uriOrTemplate: any, _config: unknown, handler: ResourceHandler): void {
|
|
137
|
+
if (typeof uriOrTemplate === 'string') {
|
|
138
|
+
this.resourceEntries.set(uriOrTemplate, { handler });
|
|
139
|
+
} else {
|
|
140
|
+
// ResourceTemplate — uriTemplate getter returns a UriTemplate object; .toString() gives the pattern string
|
|
141
|
+
const templateStr: string = uriOrTemplate.uriTemplate?.toString() ?? name;
|
|
142
|
+
this.resourceEntries.set(templateStr, { handler, template: templateStr });
|
|
143
|
+
}
|
|
122
144
|
}
|
|
123
145
|
|
|
124
146
|
async callResource(uri: string): Promise<ResourceResult> {
|
|
125
|
-
const
|
|
126
|
-
if (
|
|
127
|
-
|
|
147
|
+
const exact = this.resourceEntries.get(uri);
|
|
148
|
+
if (exact) return exact.handler(new URL(uri), {});
|
|
149
|
+
|
|
150
|
+
for (const entry of this.resourceEntries.values()) {
|
|
151
|
+
if (!entry.template) continue;
|
|
152
|
+
const variables = matchTemplate(entry.template, uri);
|
|
153
|
+
if (variables) return entry.handler(new URL(uri), variables);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
throw new Error(`Resource not registered: ${uri}`);
|
|
128
157
|
}
|
|
129
158
|
|
|
130
159
|
hasResourceRegistered(uri: string): boolean {
|
|
131
|
-
return this.
|
|
160
|
+
return this.resourceEntries.has(uri);
|
|
132
161
|
}
|
|
133
162
|
|
|
134
163
|
registeredResourceUris(): string[] {
|
|
135
|
-
return [...this.
|
|
164
|
+
return [...this.resourceEntries.keys()];
|
|
136
165
|
}
|
|
137
166
|
}
|
|
@@ -15,6 +15,20 @@ const PROJECTS_FIXTURE = [
|
|
|
15
15
|
{ id: 11, name: 'Beta', enabled: true },
|
|
16
16
|
];
|
|
17
17
|
|
|
18
|
+
// Simulates a raw MantisBT API response with extra fields that must be stripped
|
|
19
|
+
const PROJECTS_RAW_FIXTURE = [
|
|
20
|
+
{
|
|
21
|
+
id: 10,
|
|
22
|
+
name: 'Alpha',
|
|
23
|
+
enabled: true,
|
|
24
|
+
status: { id: 10, name: 'development', label: 'Entwicklung' },
|
|
25
|
+
view_state: { id: 10, name: 'public', label: 'Öffentlich' },
|
|
26
|
+
custom_fields: [
|
|
27
|
+
{ id: 1, name: 'Reklamieren', type: 'checkbox', default_value: '', possible_values: 'Ja' },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
18
32
|
const ENUM_FIXTURE = {
|
|
19
33
|
configs: [
|
|
20
34
|
{ option: 'severity_enum_string', value: '10:feature,50:minor,80:block' },
|
|
@@ -129,6 +143,33 @@ describe('mantis://projects', () => {
|
|
|
129
143
|
expect(parsed).toHaveLength(2);
|
|
130
144
|
});
|
|
131
145
|
|
|
146
|
+
it('returns minified JSON (no indentation)', async () => {
|
|
147
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ projects: PROJECTS_FIXTURE })));
|
|
148
|
+
|
|
149
|
+
const result = await mockServer.callResource('mantis://projects');
|
|
150
|
+
|
|
151
|
+
expect(result.contents[0]!.text).not.toContain('\n');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('strips custom_fields from live API response', async () => {
|
|
155
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ projects: PROJECTS_RAW_FIXTURE })));
|
|
156
|
+
|
|
157
|
+
const result = await mockServer.callResource('mantis://projects');
|
|
158
|
+
|
|
159
|
+
const parsed = JSON.parse(result.contents[0]!.text) as Array<Record<string, unknown>>;
|
|
160
|
+
expect(parsed[0]).not.toHaveProperty('custom_fields');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('preserves label on status and view_state from live API response', async () => {
|
|
164
|
+
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ projects: PROJECTS_RAW_FIXTURE })));
|
|
165
|
+
|
|
166
|
+
const result = await mockServer.callResource('mantis://projects');
|
|
167
|
+
|
|
168
|
+
const parsed = JSON.parse(result.contents[0]!.text) as Array<Record<string, unknown>>;
|
|
169
|
+
expect((parsed[0]!['status'] as Record<string, unknown>)['label']).toBe('Entwicklung');
|
|
170
|
+
expect((parsed[0]!['view_state'] as Record<string, unknown>)['label']).toBe('Öffentlich');
|
|
171
|
+
});
|
|
172
|
+
|
|
132
173
|
it('serves from cache without calling the API when cache is valid', async () => {
|
|
133
174
|
await cache.save({
|
|
134
175
|
timestamp: Date.now(),
|
|
@@ -145,6 +186,82 @@ describe('mantis://projects', () => {
|
|
|
145
186
|
});
|
|
146
187
|
});
|
|
147
188
|
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// mantis://projects/{id}
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
describe('mantis://projects/{id}', () => {
|
|
194
|
+
it('registers the template resource', () => {
|
|
195
|
+
expect(mockServer.hasResourceRegistered('mantis://projects/{id}')).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('returns combined project view from cache (users, versions, categories)', async () => {
|
|
199
|
+
const projectsFixture = [{ id: 10, name: 'Alpha', enabled: true }];
|
|
200
|
+
await cache.save({
|
|
201
|
+
timestamp: Date.now(),
|
|
202
|
+
projects: projectsFixture,
|
|
203
|
+
byProject: {
|
|
204
|
+
10: {
|
|
205
|
+
users: [{ id: 1, name: 'jsmith' }],
|
|
206
|
+
versions: [{ id: 5, name: '1.0.0', released: true }],
|
|
207
|
+
categories: [{ id: 3, name: 'Bugs' }],
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
tags: [],
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const result = await mockServer.callResource('mantis://projects/10');
|
|
214
|
+
|
|
215
|
+
const parsed = JSON.parse(result.contents[0]!.text) as Record<string, unknown>;
|
|
216
|
+
expect(parsed['id']).toBe(10);
|
|
217
|
+
expect(parsed['name']).toBe('Alpha');
|
|
218
|
+
expect(parsed['users']).toHaveLength(1);
|
|
219
|
+
expect(parsed['versions']).toHaveLength(1);
|
|
220
|
+
expect(parsed['categories']).toHaveLength(1);
|
|
221
|
+
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('fetches live data in parallel when cache is cold', async () => {
|
|
225
|
+
vi.mocked(fetch)
|
|
226
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ projects: [{ id: 10, name: 'Alpha', categories: [{ id: 3, name: 'Bugs' }] }] })))
|
|
227
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ users: [{ id: 1, name: 'jsmith' }] })))
|
|
228
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ versions: [{ id: 5, name: '1.0.0' }] })));
|
|
229
|
+
|
|
230
|
+
const result = await mockServer.callResource('mantis://projects/10');
|
|
231
|
+
|
|
232
|
+
const parsed = JSON.parse(result.contents[0]!.text) as Record<string, unknown>;
|
|
233
|
+
expect(parsed['id']).toBe(10);
|
|
234
|
+
expect((parsed['users'] as unknown[]).length).toBeGreaterThan(0);
|
|
235
|
+
expect((parsed['versions'] as unknown[]).length).toBeGreaterThan(0);
|
|
236
|
+
expect((parsed['categories'] as unknown[]).length).toBeGreaterThan(0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('strips [All Projects] prefix from category names on live fetch', async () => {
|
|
240
|
+
vi.mocked(fetch)
|
|
241
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ projects: [{ id: 10, name: 'Alpha', categories: [{ id: 1, name: '[All Projects] General' }] }] })))
|
|
242
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ users: [] })))
|
|
243
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify({ versions: [] })));
|
|
244
|
+
|
|
245
|
+
const result = await mockServer.callResource('mantis://projects/10');
|
|
246
|
+
|
|
247
|
+
const parsed = JSON.parse(result.contents[0]!.text) as { categories: Array<{ name: string }> };
|
|
248
|
+
expect(parsed.categories[0]!.name).toBe('General');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('returns minified JSON', async () => {
|
|
252
|
+
await cache.save({
|
|
253
|
+
timestamp: Date.now(),
|
|
254
|
+
projects: [{ id: 10, name: 'Alpha' }],
|
|
255
|
+
byProject: { 10: { users: [], versions: [], categories: [] } },
|
|
256
|
+
tags: [],
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const result = await mockServer.callResource('mantis://projects/10');
|
|
260
|
+
|
|
261
|
+
expect(result.contents[0]!.text).not.toContain('\n');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
148
265
|
// ---------------------------------------------------------------------------
|
|
149
266
|
// mantis://enums
|
|
150
267
|
// ---------------------------------------------------------------------------
|
|
@@ -93,8 +93,46 @@ describe('get_metadata', () => {
|
|
|
93
93
|
expect(result.isError).toBeUndefined();
|
|
94
94
|
// fetch must NOT have been called — data came from cache
|
|
95
95
|
expect(fetch).not.toHaveBeenCalled();
|
|
96
|
-
const parsed = JSON.parse(result.content[0]!.text) as
|
|
97
|
-
expect(parsed.projects).
|
|
96
|
+
const parsed = JSON.parse(result.content[0]!.text) as { projects: number };
|
|
97
|
+
expect(parsed.projects).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns compact summary (no raw arrays)', async () => {
|
|
101
|
+
const metadata = makeSampleMetadata();
|
|
102
|
+
vi.mocked(readFile).mockResolvedValue(makeValidMetadataCacheFile(metadata) as any);
|
|
103
|
+
|
|
104
|
+
const result = await mockServer.callTool('get_metadata', {});
|
|
105
|
+
const parsed = JSON.parse(result.content[0]!.text) as Record<string, unknown>;
|
|
106
|
+
expect(typeof parsed['projects']).toBe('number');
|
|
107
|
+
expect(typeof parsed['tags']).toBe('number');
|
|
108
|
+
expect(Array.isArray(parsed['projects'])).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('byProject contains name and counts per project', async () => {
|
|
112
|
+
const metadata: CachedMetadata = {
|
|
113
|
+
timestamp: Date.now(),
|
|
114
|
+
projects: [{ id: 1, name: 'Test Project' }],
|
|
115
|
+
byProject: { 1: { users: [{ id: 1, name: 'u' }], versions: [{ id: 1, name: 'v1' }], categories: [{ id: 1, name: 'c' }, { id: 2, name: 'c2' }] } },
|
|
116
|
+
tags: [],
|
|
117
|
+
};
|
|
118
|
+
vi.mocked(readFile).mockResolvedValue(makeValidMetadataCacheFile(metadata) as any);
|
|
119
|
+
|
|
120
|
+
const result = await mockServer.callTool('get_metadata', {});
|
|
121
|
+
const parsed = JSON.parse(result.content[0]!.text) as { byProject: Record<string, { name: string; users: number; versions: number; categories: number }> };
|
|
122
|
+
const entry = Object.values(parsed.byProject)[0]!;
|
|
123
|
+
expect(typeof entry.name).toBe('string');
|
|
124
|
+
expect(typeof entry.users).toBe('number');
|
|
125
|
+
expect(typeof entry.versions).toBe('number');
|
|
126
|
+
expect(typeof entry.categories).toBe('number');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('ttl_seconds is positive for fresh cache', async () => {
|
|
130
|
+
const metadata = makeSampleMetadata();
|
|
131
|
+
vi.mocked(readFile).mockResolvedValue(makeValidMetadataCacheFile(metadata) as any);
|
|
132
|
+
|
|
133
|
+
const result = await mockServer.callTool('get_metadata', {});
|
|
134
|
+
const parsed = JSON.parse(result.content[0]!.text) as { ttl_seconds: number };
|
|
135
|
+
expect(parsed.ttl_seconds).toBeGreaterThan(0);
|
|
98
136
|
});
|
|
99
137
|
|
|
100
138
|
it('fetches and caches when cache is missing', async () => {
|
|
@@ -118,6 +156,42 @@ describe('get_metadata', () => {
|
|
|
118
156
|
});
|
|
119
157
|
});
|
|
120
158
|
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// get_metadata_full
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
describe('get_metadata_full', () => {
|
|
164
|
+
it('is registered', () => {
|
|
165
|
+
expect(mockServer.hasToolRegistered('get_metadata_full')).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('returns full raw cache (arrays, not counts)', async () => {
|
|
169
|
+
const metadata = makeSampleMetadata();
|
|
170
|
+
vi.mocked(readFile).mockResolvedValue(makeValidMetadataCacheFile(metadata) as any);
|
|
171
|
+
|
|
172
|
+
const result = await mockServer.callTool('get_metadata_full', {});
|
|
173
|
+
const parsed = JSON.parse(result.content[0]!.text) as { projects: unknown[]; tags: unknown[] };
|
|
174
|
+
expect(Array.isArray(parsed.projects)).toBe(true);
|
|
175
|
+
expect(Array.isArray(parsed.tags)).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('returns minified JSON (no newlines)', async () => {
|
|
179
|
+
const metadata = makeSampleMetadata();
|
|
180
|
+
vi.mocked(readFile).mockResolvedValue(makeValidMetadataCacheFile(metadata) as any);
|
|
181
|
+
|
|
182
|
+
const result = await mockServer.callTool('get_metadata_full', {});
|
|
183
|
+
expect(result.content[0]!.text).not.toContain('\n');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('uses cache without fetching', async () => {
|
|
187
|
+
const metadata = makeSampleMetadata();
|
|
188
|
+
vi.mocked(readFile).mockResolvedValue(makeValidMetadataCacheFile(metadata) as any);
|
|
189
|
+
|
|
190
|
+
await mockServer.callTool('get_metadata_full', {});
|
|
191
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
121
195
|
// ---------------------------------------------------------------------------
|
|
122
196
|
// sync_metadata
|
|
123
197
|
// ---------------------------------------------------------------------------
|
|
@@ -158,6 +232,58 @@ describe('sync_metadata', () => {
|
|
|
158
232
|
expect(written.data.tags).toHaveLength(2);
|
|
159
233
|
});
|
|
160
234
|
|
|
235
|
+
it('strips custom_fields from projects before writing to cache', async () => {
|
|
236
|
+
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
|
|
237
|
+
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
238
|
+
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
239
|
+
|
|
240
|
+
const projectsResponse = {
|
|
241
|
+
projects: [{
|
|
242
|
+
id: 1,
|
|
243
|
+
name: 'Test Project',
|
|
244
|
+
status: { id: 10, name: 'development', label: 'Entwicklung' },
|
|
245
|
+
custom_fields: [{ id: 6, name: 'Reklamieren', type: 'checkbox' }],
|
|
246
|
+
}],
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
vi.mocked(fetch)
|
|
250
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify(projectsResponse)))
|
|
251
|
+
.mockResolvedValue(makeResponse(200, JSON.stringify({ users: [], versions: [], projects: [{ id: 1, categories: [] }], tags: [] })));
|
|
252
|
+
|
|
253
|
+
await mockServer.callTool('sync_metadata', {});
|
|
254
|
+
|
|
255
|
+
const writeCall = vi.mocked(writeFile).mock.calls.find(call => String(call[0]).includes('metadata.json'));
|
|
256
|
+
const written = JSON.parse(writeCall![1] as string) as { data: { projects: Array<Record<string, unknown>> } };
|
|
257
|
+
expect(written.data.projects[0]).not.toHaveProperty('custom_fields');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('preserves label on status and view_state when writing to cache', async () => {
|
|
261
|
+
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
|
|
262
|
+
vi.mocked(mkdir).mockResolvedValue(undefined);
|
|
263
|
+
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
264
|
+
|
|
265
|
+
const projectsResponse = {
|
|
266
|
+
projects: [{
|
|
267
|
+
id: 1,
|
|
268
|
+
name: 'Test Project',
|
|
269
|
+
status: { id: 10, name: 'development', label: 'Entwicklung' },
|
|
270
|
+
view_state: { id: 10, name: 'public', label: 'Öffentlich' },
|
|
271
|
+
}],
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
vi.mocked(fetch)
|
|
275
|
+
.mockResolvedValueOnce(makeResponse(200, JSON.stringify(projectsResponse)))
|
|
276
|
+
.mockResolvedValue(makeResponse(200, JSON.stringify({ users: [], versions: [], projects: [{ id: 1, categories: [] }], tags: [] })));
|
|
277
|
+
|
|
278
|
+
await mockServer.callTool('sync_metadata', {});
|
|
279
|
+
|
|
280
|
+
const writeCall = vi.mocked(writeFile).mock.calls.find(call => String(call[0]).includes('metadata.json'));
|
|
281
|
+
const written = JSON.parse(writeCall![1] as string) as { data: { projects: Array<Record<string, unknown>> } };
|
|
282
|
+
const project = written.data.projects[0]!;
|
|
283
|
+
expect((project['status'] as Record<string, unknown>)['label']).toBe('Entwicklung');
|
|
284
|
+
expect((project['view_state'] as Record<string, unknown>)['label']).toBe('Öffentlich');
|
|
285
|
+
});
|
|
286
|
+
|
|
161
287
|
it('fetches versions with obsolete=1 and inherit=1', async () => {
|
|
162
288
|
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
|
|
163
289
|
vi.mocked(mkdir).mockResolvedValue(undefined);
|