@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/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 `get_metadata`.
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`.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dpesch/mantisbt-mcp-server",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "mcpName": "io.github.dpesch/mantisbt-mcp-server",
5
5
  "description": "MCP server for MantisBT REST API – read and manage bug tracker issues",
6
6
  "author": "Dominik Pesch",
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.7.0",
6
+ "version": "1.8.0",
7
7
  "packages": [
8
8
  {
9
9
  "registryType": "npm",
10
10
  "identifier": "@dpesch/mantisbt-mcp-server",
11
- "version": "1.7.0",
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 resourceHandlers = new Map<string, ResourceHandler>();
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
- if (options.validate) {
82
- const schema = this.schemas.get(name);
83
- if (schema) {
84
- const parsed = schema.safeParse(args);
85
- if (!parsed.success) {
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
- registerResource(name: string, uri: string, _config: unknown, handler: ResourceHandler): void {
121
- this.resourceHandlers.set(uri, handler);
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 handler = this.resourceHandlers.get(uri);
126
- if (!handler) throw new Error(`Resource not registered: ${uri}`);
127
- return handler(new URL(uri));
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.resourceHandlers.has(uri);
160
+ return this.resourceEntries.has(uri);
132
161
  }
133
162
 
134
163
  registeredResourceUris(): string[] {
135
- return [...this.resourceHandlers.keys()];
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 CachedMetadata;
97
- expect(parsed.projects).toEqual(metadata.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);